pycaption 2.3.6__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.6/pycaption.egg-info → pycaption-2.3.8}/PKG-INFO +1 -1
- {pycaption-2.3.6 → pycaption-2.3.8}/pycaption/dfxp/writer.py +53 -3
- {pycaption-2.3.6 → pycaption-2.3.8}/pycaption/geometry.py +22 -0
- {pycaption-2.3.6 → pycaption-2.3.8}/pycaption/sami/reader.py +5 -7
- {pycaption-2.3.6 → pycaption-2.3.8}/pycaption/sami/writer.py +2 -4
- {pycaption-2.3.6 → pycaption-2.3.8}/pycaption/scc/writer.py +15 -11
- {pycaption-2.3.6 → pycaption-2.3.8}/pycaption/webvtt/constants.py +11 -2
- {pycaption-2.3.6 → pycaption-2.3.8}/pycaption/webvtt/reader.py +61 -8
- {pycaption-2.3.6 → pycaption-2.3.8}/pycaption/webvtt/writer.py +12 -1
- {pycaption-2.3.6 → pycaption-2.3.8/pycaption.egg-info}/PKG-INFO +1 -1
- {pycaption-2.3.6 → pycaption-2.3.8}/setup.py +1 -1
- {pycaption-2.3.6 → pycaption-2.3.8}/tests/test_dfxp.py +3 -0
- {pycaption-2.3.6 → pycaption-2.3.8}/tests/test_sami.py +16 -0
- {pycaption-2.3.6 → pycaption-2.3.8}/tests/test_scc.py +4 -0
- {pycaption-2.3.6 → pycaption-2.3.8}/tests/test_scc_writer.py +61 -0
- {pycaption-2.3.6 → pycaption-2.3.8}/tests/test_webvtt.py +299 -2
- {pycaption-2.3.6 → pycaption-2.3.8}/tests/test_webvtt_conversion.py +155 -0
- {pycaption-2.3.6 → pycaption-2.3.8}/LICENSE +0 -0
- {pycaption-2.3.6 → pycaption-2.3.8}/MANIFEST.in +0 -0
- {pycaption-2.3.6 → pycaption-2.3.8}/README.rst +0 -0
- {pycaption-2.3.6 → pycaption-2.3.8}/pycaption/__init__.py +0 -0
- {pycaption-2.3.6 → pycaption-2.3.8}/pycaption/base.py +0 -0
- {pycaption-2.3.6 → pycaption-2.3.8}/pycaption/dfxp/__init__.py +0 -0
- {pycaption-2.3.6 → pycaption-2.3.8}/pycaption/dfxp/constants.py +0 -0
- {pycaption-2.3.6 → pycaption-2.3.8}/pycaption/dfxp/extras.py +0 -0
- {pycaption-2.3.6 → pycaption-2.3.8}/pycaption/dfxp/reader.py +0 -0
- {pycaption-2.3.6 → pycaption-2.3.8}/pycaption/exceptions.py +0 -0
- {pycaption-2.3.6 → pycaption-2.3.8}/pycaption/microdvd.py +0 -0
- {pycaption-2.3.6 → pycaption-2.3.8}/pycaption/sami/__init__.py +0 -0
- {pycaption-2.3.6 → pycaption-2.3.8}/pycaption/sami/constants.py +0 -0
- {pycaption-2.3.6 → pycaption-2.3.8}/pycaption/sami/parser.py +0 -0
- {pycaption-2.3.6 → pycaption-2.3.8}/pycaption/scc/__init__.py +0 -0
- {pycaption-2.3.6 → pycaption-2.3.8}/pycaption/scc/constants.py +0 -0
- {pycaption-2.3.6 → pycaption-2.3.8}/pycaption/scc/reader.py +0 -0
- {pycaption-2.3.6 → pycaption-2.3.8}/pycaption/scc/specialized_collections.py +0 -0
- {pycaption-2.3.6 → pycaption-2.3.8}/pycaption/scc/state_machines.py +0 -0
- {pycaption-2.3.6 → pycaption-2.3.8}/pycaption/scc/translator.py +0 -0
- {pycaption-2.3.6 → pycaption-2.3.8}/pycaption/srt.py +0 -0
- {pycaption-2.3.6 → pycaption-2.3.8}/pycaption/transcript.py +0 -0
- {pycaption-2.3.6 → pycaption-2.3.8}/pycaption/utils.py +0 -0
- {pycaption-2.3.6 → pycaption-2.3.8}/pycaption/webvtt/__init__.py +0 -0
- {pycaption-2.3.6 → pycaption-2.3.8}/pycaption.egg-info/SOURCES.txt +0 -0
- {pycaption-2.3.6 → pycaption-2.3.8}/pycaption.egg-info/dependency_links.txt +0 -0
- {pycaption-2.3.6 → pycaption-2.3.8}/pycaption.egg-info/requires.txt +0 -0
- {pycaption-2.3.6 → pycaption-2.3.8}/pycaption.egg-info/top_level.txt +0 -0
- {pycaption-2.3.6 → pycaption-2.3.8}/setup.cfg +0 -0
- {pycaption-2.3.6 → pycaption-2.3.8}/tests/__init__.py +0 -0
- {pycaption-2.3.6 → pycaption-2.3.8}/tests/conftest.py +0 -0
- {pycaption-2.3.6 → pycaption-2.3.8}/tests/fixtures/__init__.py +0 -0
- {pycaption-2.3.6 → pycaption-2.3.8}/tests/fixtures/dfxp.py +0 -0
- {pycaption-2.3.6 → pycaption-2.3.8}/tests/fixtures/microdvd.py +0 -0
- {pycaption-2.3.6 → pycaption-2.3.8}/tests/fixtures/sami.py +0 -0
- {pycaption-2.3.6 → pycaption-2.3.8}/tests/fixtures/scc.py +0 -0
- {pycaption-2.3.6 → pycaption-2.3.8}/tests/fixtures/srt.py +0 -0
- {pycaption-2.3.6 → pycaption-2.3.8}/tests/fixtures/translated_scc.py +0 -0
- {pycaption-2.3.6 → pycaption-2.3.8}/tests/fixtures/webvtt.py +0 -0
- {pycaption-2.3.6 → pycaption-2.3.8}/tests/mixins.py +0 -0
- {pycaption-2.3.6 → pycaption-2.3.8}/tests/test_base.py +0 -0
- {pycaption-2.3.6 → pycaption-2.3.8}/tests/test_bytes_input.py +0 -0
- {pycaption-2.3.6 → pycaption-2.3.8}/tests/test_dfxp_conversion.py +0 -0
- {pycaption-2.3.6 → pycaption-2.3.8}/tests/test_dfxp_extras.py +0 -0
- {pycaption-2.3.6 → pycaption-2.3.8}/tests/test_double_encoding.py +0 -0
- {pycaption-2.3.6 → pycaption-2.3.8}/tests/test_functions.py +0 -0
- {pycaption-2.3.6 → pycaption-2.3.8}/tests/test_geometry.py +0 -0
- {pycaption-2.3.6 → pycaption-2.3.8}/tests/test_microdvd.py +0 -0
- {pycaption-2.3.6 → pycaption-2.3.8}/tests/test_microdvd_conversion.py +0 -0
- {pycaption-2.3.6 → pycaption-2.3.8}/tests/test_sami_conversion.py +0 -0
- {pycaption-2.3.6 → pycaption-2.3.8}/tests/test_scc_conversion.py +0 -0
- {pycaption-2.3.6 → pycaption-2.3.8}/tests/test_scc_translator.py +0 -0
- {pycaption-2.3.6 → pycaption-2.3.8}/tests/test_srt.py +0 -0
- {pycaption-2.3.6 → 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):
|
|
@@ -7,10 +7,7 @@ styles, margin-based positioning, and multi-language content.
|
|
|
7
7
|
from bs4 import BeautifulSoup, NavigableString
|
|
8
8
|
|
|
9
9
|
from ..base import BaseReader, Caption, CaptionList, CaptionNode, CaptionSet
|
|
10
|
-
from ..exceptions import
|
|
11
|
-
CaptionReadNoCaptions,
|
|
12
|
-
CaptionReadTimingError,
|
|
13
|
-
)
|
|
10
|
+
from ..exceptions import CaptionReadNoCaptions, CaptionReadTimingError
|
|
14
11
|
from ..geometry import Alignment, HorizontalAlignmentEnum, Layout, Padding, Size
|
|
15
12
|
from .parser import SAMIParser
|
|
16
13
|
|
|
@@ -65,7 +62,8 @@ class SAMIReader(BaseReader):
|
|
|
65
62
|
caption_dict[language] = lang_captions
|
|
66
63
|
|
|
67
64
|
caption_set = CaptionSet(
|
|
68
|
-
caption_dict,
|
|
65
|
+
caption_dict,
|
|
66
|
+
layout_info=global_layout,
|
|
69
67
|
visual_alignment_default=HorizontalAlignmentEnum.LEFT,
|
|
70
68
|
)
|
|
71
69
|
|
|
@@ -244,7 +242,7 @@ class SAMIReader(BaseReader):
|
|
|
244
242
|
attrs = {}
|
|
245
243
|
css_attrs = tag.attrs
|
|
246
244
|
|
|
247
|
-
if "class" in css_attrs:
|
|
245
|
+
if "class" in css_attrs and css_attrs["class"]:
|
|
248
246
|
attrs["class"] = css_attrs["class"][0].lower()
|
|
249
247
|
if "id" in css_attrs:
|
|
250
248
|
attrs["class"] = css_attrs["id"].lower()
|
|
@@ -265,7 +263,7 @@ class SAMIReader(BaseReader):
|
|
|
265
263
|
for style in styles:
|
|
266
264
|
style = style.split(":")
|
|
267
265
|
if len(style) == 2:
|
|
268
|
-
css_property, value = style
|
|
266
|
+
css_property, value = style[0].strip(), style[1]
|
|
269
267
|
else:
|
|
270
268
|
continue
|
|
271
269
|
if css_property == "text-align":
|
|
@@ -42,9 +42,7 @@ class SAMIWriter(BaseWriter):
|
|
|
42
42
|
"""Serialize a CaptionSet into a SAMI document string."""
|
|
43
43
|
caption_set = deepcopy(caption_set)
|
|
44
44
|
source_default = self._get_visual_alignment_default(caption_set)
|
|
45
|
-
self._promote_center =
|
|
46
|
-
source_default == HorizontalAlignmentEnum.CENTER
|
|
47
|
-
)
|
|
45
|
+
self._promote_center = source_default == HorizontalAlignmentEnum.CENTER
|
|
48
46
|
sami = BeautifulSoup(SAMI_BASE_MARKUP, "lxml-xml")
|
|
49
47
|
|
|
50
48
|
caption_set.layout_info = self._relativize_and_fit_to_screen(
|
|
@@ -310,7 +308,7 @@ class SAMIWriter(BaseWriter):
|
|
|
310
308
|
|
|
311
309
|
style = ""
|
|
312
310
|
klass = ""
|
|
313
|
-
if "classes" in content:
|
|
311
|
+
if "classes" in content and content["classes"]:
|
|
314
312
|
klass += f' class="{" ".join(content["classes"])}"'
|
|
315
313
|
elif "class" in content:
|
|
316
314
|
klass += f' class="{content["class"]}"'
|
|
@@ -99,6 +99,7 @@ class SCCWriter(BaseWriter):
|
|
|
99
99
|
codes = self._encode_all(classified)
|
|
100
100
|
codes = self._adjust_timing(codes)
|
|
101
101
|
codes = self._deduplicate_timestamps(codes)
|
|
102
|
+
codes = self._clamp_end_times(codes)
|
|
102
103
|
output += self._render_mixed(codes)
|
|
103
104
|
|
|
104
105
|
return output
|
|
@@ -195,6 +196,17 @@ class SCCWriter(BaseWriter):
|
|
|
195
196
|
last_emitted_frame = self._microseconds_to_frame(start)
|
|
196
197
|
return codes
|
|
197
198
|
|
|
199
|
+
@staticmethod
|
|
200
|
+
def _clamp_end_times(codes):
|
|
201
|
+
"""Suppress end times that drift behind start after timing adjustments.
|
|
202
|
+
When dense cues push start forward beyond the original end, emit no
|
|
203
|
+
clear command (end=None) rather than producing an invalid negative-
|
|
204
|
+
duration cue."""
|
|
205
|
+
for index, (code, start, end, mode, depth) in enumerate(codes):
|
|
206
|
+
if end is not None and end <= start:
|
|
207
|
+
codes[index] = (code, start, None, mode, depth)
|
|
208
|
+
return codes
|
|
209
|
+
|
|
198
210
|
def _render_mixed(self, codes):
|
|
199
211
|
"""Serialize all cues in chronological order, emitting mode-appropriate
|
|
200
212
|
preambles inline. Handles pop-on (ENM+RCL...EDM+EOC), roll-up
|
|
@@ -225,12 +237,7 @@ class SCCWriter(BaseWriter):
|
|
|
225
237
|
code_tokens = code.split()
|
|
226
238
|
|
|
227
239
|
if len(code_tokens) <= max_payload:
|
|
228
|
-
return
|
|
229
|
-
f"{ts}\t"
|
|
230
|
-
"94ae 94ae 9420 9420 "
|
|
231
|
-
f"{code}"
|
|
232
|
-
"942c 942c 942f 942f\n\n"
|
|
233
|
-
)
|
|
240
|
+
return f"{ts}\t" "94ae 94ae 9420 9420 " f"{code}" "942c 942c 942f 942f\n\n"
|
|
234
241
|
|
|
235
242
|
output = ""
|
|
236
243
|
offset = 0
|
|
@@ -243,11 +250,7 @@ class SCCWriter(BaseWriter):
|
|
|
243
250
|
is_last = offset + max_payload >= len(code_tokens)
|
|
244
251
|
if is_last:
|
|
245
252
|
line = line + ["942c", "942c", "942f", "942f"]
|
|
246
|
-
output += (
|
|
247
|
-
f"{self._format_timestamp(start)}\t"
|
|
248
|
-
+ " ".join(line)
|
|
249
|
-
+ "\n\n"
|
|
250
|
-
)
|
|
253
|
+
output += f"{self._format_timestamp(start)}\t" + " ".join(line) + "\n\n"
|
|
251
254
|
offset += max_payload
|
|
252
255
|
if not is_last:
|
|
253
256
|
start += MICROSECONDS_PER_CODEWORD
|
|
@@ -329,6 +332,7 @@ class SCCWriter(BaseWriter):
|
|
|
329
332
|
base_row = max(1, min(15, round((y.value - 5) / 90.0 * 15) + 1))
|
|
330
333
|
else:
|
|
331
334
|
base_row = 15
|
|
335
|
+
base_row = max(1, min(base_row, 16 - num_lines))
|
|
332
336
|
row = base_row + line_index
|
|
333
337
|
return min(row, 15)
|
|
334
338
|
return max(1, min(15, 16 - num_lines + line_index))
|
|
@@ -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
|
},
|