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.
Files changed (71) hide show
  1. {pycaption-2.3.7/pycaption.egg-info → pycaption-2.3.8}/PKG-INFO +1 -1
  2. {pycaption-2.3.7 → pycaption-2.3.8}/pycaption/dfxp/writer.py +53 -3
  3. {pycaption-2.3.7 → pycaption-2.3.8}/pycaption/geometry.py +22 -0
  4. {pycaption-2.3.7 → pycaption-2.3.8}/pycaption/sami/reader.py +1 -1
  5. {pycaption-2.3.7 → pycaption-2.3.8}/pycaption/webvtt/constants.py +11 -2
  6. {pycaption-2.3.7 → pycaption-2.3.8}/pycaption/webvtt/reader.py +61 -8
  7. {pycaption-2.3.7 → pycaption-2.3.8}/pycaption/webvtt/writer.py +12 -1
  8. {pycaption-2.3.7 → pycaption-2.3.8/pycaption.egg-info}/PKG-INFO +1 -1
  9. {pycaption-2.3.7 → pycaption-2.3.8}/setup.py +1 -1
  10. {pycaption-2.3.7 → pycaption-2.3.8}/tests/test_dfxp.py +3 -0
  11. {pycaption-2.3.7 → pycaption-2.3.8}/tests/test_sami.py +16 -0
  12. {pycaption-2.3.7 → pycaption-2.3.8}/tests/test_scc.py +4 -0
  13. {pycaption-2.3.7 → pycaption-2.3.8}/tests/test_webvtt.py +299 -2
  14. {pycaption-2.3.7 → pycaption-2.3.8}/tests/test_webvtt_conversion.py +133 -0
  15. {pycaption-2.3.7 → pycaption-2.3.8}/LICENSE +0 -0
  16. {pycaption-2.3.7 → pycaption-2.3.8}/MANIFEST.in +0 -0
  17. {pycaption-2.3.7 → pycaption-2.3.8}/README.rst +0 -0
  18. {pycaption-2.3.7 → pycaption-2.3.8}/pycaption/__init__.py +0 -0
  19. {pycaption-2.3.7 → pycaption-2.3.8}/pycaption/base.py +0 -0
  20. {pycaption-2.3.7 → pycaption-2.3.8}/pycaption/dfxp/__init__.py +0 -0
  21. {pycaption-2.3.7 → pycaption-2.3.8}/pycaption/dfxp/constants.py +0 -0
  22. {pycaption-2.3.7 → pycaption-2.3.8}/pycaption/dfxp/extras.py +0 -0
  23. {pycaption-2.3.7 → pycaption-2.3.8}/pycaption/dfxp/reader.py +0 -0
  24. {pycaption-2.3.7 → pycaption-2.3.8}/pycaption/exceptions.py +0 -0
  25. {pycaption-2.3.7 → pycaption-2.3.8}/pycaption/microdvd.py +0 -0
  26. {pycaption-2.3.7 → pycaption-2.3.8}/pycaption/sami/__init__.py +0 -0
  27. {pycaption-2.3.7 → pycaption-2.3.8}/pycaption/sami/constants.py +0 -0
  28. {pycaption-2.3.7 → pycaption-2.3.8}/pycaption/sami/parser.py +0 -0
  29. {pycaption-2.3.7 → pycaption-2.3.8}/pycaption/sami/writer.py +0 -0
  30. {pycaption-2.3.7 → pycaption-2.3.8}/pycaption/scc/__init__.py +0 -0
  31. {pycaption-2.3.7 → pycaption-2.3.8}/pycaption/scc/constants.py +0 -0
  32. {pycaption-2.3.7 → pycaption-2.3.8}/pycaption/scc/reader.py +0 -0
  33. {pycaption-2.3.7 → pycaption-2.3.8}/pycaption/scc/specialized_collections.py +0 -0
  34. {pycaption-2.3.7 → pycaption-2.3.8}/pycaption/scc/state_machines.py +0 -0
  35. {pycaption-2.3.7 → pycaption-2.3.8}/pycaption/scc/translator.py +0 -0
  36. {pycaption-2.3.7 → pycaption-2.3.8}/pycaption/scc/writer.py +0 -0
  37. {pycaption-2.3.7 → pycaption-2.3.8}/pycaption/srt.py +0 -0
  38. {pycaption-2.3.7 → pycaption-2.3.8}/pycaption/transcript.py +0 -0
  39. {pycaption-2.3.7 → pycaption-2.3.8}/pycaption/utils.py +0 -0
  40. {pycaption-2.3.7 → pycaption-2.3.8}/pycaption/webvtt/__init__.py +0 -0
  41. {pycaption-2.3.7 → pycaption-2.3.8}/pycaption.egg-info/SOURCES.txt +0 -0
  42. {pycaption-2.3.7 → pycaption-2.3.8}/pycaption.egg-info/dependency_links.txt +0 -0
  43. {pycaption-2.3.7 → pycaption-2.3.8}/pycaption.egg-info/requires.txt +0 -0
  44. {pycaption-2.3.7 → pycaption-2.3.8}/pycaption.egg-info/top_level.txt +0 -0
  45. {pycaption-2.3.7 → pycaption-2.3.8}/setup.cfg +0 -0
  46. {pycaption-2.3.7 → pycaption-2.3.8}/tests/__init__.py +0 -0
  47. {pycaption-2.3.7 → pycaption-2.3.8}/tests/conftest.py +0 -0
  48. {pycaption-2.3.7 → pycaption-2.3.8}/tests/fixtures/__init__.py +0 -0
  49. {pycaption-2.3.7 → pycaption-2.3.8}/tests/fixtures/dfxp.py +0 -0
  50. {pycaption-2.3.7 → pycaption-2.3.8}/tests/fixtures/microdvd.py +0 -0
  51. {pycaption-2.3.7 → pycaption-2.3.8}/tests/fixtures/sami.py +0 -0
  52. {pycaption-2.3.7 → pycaption-2.3.8}/tests/fixtures/scc.py +0 -0
  53. {pycaption-2.3.7 → pycaption-2.3.8}/tests/fixtures/srt.py +0 -0
  54. {pycaption-2.3.7 → pycaption-2.3.8}/tests/fixtures/translated_scc.py +0 -0
  55. {pycaption-2.3.7 → pycaption-2.3.8}/tests/fixtures/webvtt.py +0 -0
  56. {pycaption-2.3.7 → pycaption-2.3.8}/tests/mixins.py +0 -0
  57. {pycaption-2.3.7 → pycaption-2.3.8}/tests/test_base.py +0 -0
  58. {pycaption-2.3.7 → pycaption-2.3.8}/tests/test_bytes_input.py +0 -0
  59. {pycaption-2.3.7 → pycaption-2.3.8}/tests/test_dfxp_conversion.py +0 -0
  60. {pycaption-2.3.7 → pycaption-2.3.8}/tests/test_dfxp_extras.py +0 -0
  61. {pycaption-2.3.7 → pycaption-2.3.8}/tests/test_double_encoding.py +0 -0
  62. {pycaption-2.3.7 → pycaption-2.3.8}/tests/test_functions.py +0 -0
  63. {pycaption-2.3.7 → pycaption-2.3.8}/tests/test_geometry.py +0 -0
  64. {pycaption-2.3.7 → pycaption-2.3.8}/tests/test_microdvd.py +0 -0
  65. {pycaption-2.3.7 → pycaption-2.3.8}/tests/test_microdvd_conversion.py +0 -0
  66. {pycaption-2.3.7 → pycaption-2.3.8}/tests/test_sami_conversion.py +0 -0
  67. {pycaption-2.3.7 → pycaption-2.3.8}/tests/test_scc_conversion.py +0 -0
  68. {pycaption-2.3.7 → pycaption-2.3.8}/tests/test_scc_translator.py +0 -0
  69. {pycaption-2.3.7 → pycaption-2.3.8}/tests/test_scc_writer.py +0 -0
  70. {pycaption-2.3.7 → pycaption-2.3.8}/tests/test_srt.py +0 -0
  71. {pycaption-2.3.7 → pycaption-2.3.8}/tests/test_srt_conversion.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pycaption
3
- Version: 2.3.7
3
+ Version: 2.3.8
4
4
  Summary: Closed caption converter
5
5
  Author: Joe Norton
6
6
  Author-email: joey@nortoncrew.com
@@ -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-align, display-align)
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(r"::cue(?:\((\.?[\w-]+)\))?\s*\{([^}]*)\}")
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, _ = WebVTTReader._split_alignment(parsed.get("line", ""))
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
- origin = Point(x, origin_y)
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
- Only maps properties pycaption can represent: font-style,
1010
- font-weight, text-decoration, color, background-color.
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) → each class in order. Existing
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
- cue_settings += f" line:{top_offset}"
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
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pycaption
3
- Version: 2.3.7
3
+ Version: 2.3.8
4
4
  Summary: Closed caption converter
5
5
  Author: Joe Norton
6
6
  Author-email: joey@nortoncrew.com
@@ -20,7 +20,7 @@ transcript_dependencies = ["nltk==3.10.0"]
20
20
 
21
21
  setup(
22
22
  name="pycaption",
23
- version="2.3.7",
23
+ version="2.3.8",
24
24
  description="Closed caption converter",
25
25
  long_description=open(README_PATH).read(),
26
26
  author="Joe Norton",
@@ -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 test_position_only_no_origin(self):
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
- assert layout.origin is None
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