pycaption 2.3.7__tar.gz → 2.3.9__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.9}/PKG-INFO +1 -1
- {pycaption-2.3.7 → pycaption-2.3.9}/pycaption/dfxp/writer.py +53 -3
- {pycaption-2.3.7 → pycaption-2.3.9}/pycaption/geometry.py +22 -0
- {pycaption-2.3.7 → pycaption-2.3.9}/pycaption/sami/parser.py +4 -0
- {pycaption-2.3.7 → pycaption-2.3.9}/pycaption/sami/reader.py +8 -2
- {pycaption-2.3.7 → pycaption-2.3.9}/pycaption/scc/reader.py +16 -6
- {pycaption-2.3.7 → pycaption-2.3.9}/pycaption/scc/specialized_collections.py +31 -19
- {pycaption-2.3.7 → pycaption-2.3.9}/pycaption/scc/state_machines.py +48 -19
- {pycaption-2.3.7 → pycaption-2.3.9}/pycaption/srt.py +20 -10
- {pycaption-2.3.7 → pycaption-2.3.9}/pycaption/webvtt/constants.py +11 -2
- {pycaption-2.3.7 → pycaption-2.3.9}/pycaption/webvtt/reader.py +61 -8
- {pycaption-2.3.7 → pycaption-2.3.9}/pycaption/webvtt/writer.py +12 -1
- {pycaption-2.3.7 → pycaption-2.3.9/pycaption.egg-info}/PKG-INFO +1 -1
- {pycaption-2.3.7 → pycaption-2.3.9}/setup.py +1 -1
- {pycaption-2.3.7 → pycaption-2.3.9}/tests/conftest.py +8 -0
- {pycaption-2.3.7 → pycaption-2.3.9}/tests/fixtures/scc.py +117 -0
- {pycaption-2.3.7 → pycaption-2.3.9}/tests/test_base.py +82 -9
- {pycaption-2.3.7 → pycaption-2.3.9}/tests/test_dfxp.py +3 -0
- {pycaption-2.3.7 → pycaption-2.3.9}/tests/test_sami.py +36 -0
- {pycaption-2.3.7 → pycaption-2.3.9}/tests/test_scc.py +232 -3
- pycaption-2.3.9/tests/test_scc_conversion.py +275 -0
- {pycaption-2.3.7 → pycaption-2.3.9}/tests/test_srt.py +19 -0
- {pycaption-2.3.7 → pycaption-2.3.9}/tests/test_webvtt.py +299 -2
- {pycaption-2.3.7 → pycaption-2.3.9}/tests/test_webvtt_conversion.py +133 -0
- pycaption-2.3.7/tests/test_scc_conversion.py +0 -117
- {pycaption-2.3.7 → pycaption-2.3.9}/LICENSE +0 -0
- {pycaption-2.3.7 → pycaption-2.3.9}/MANIFEST.in +0 -0
- {pycaption-2.3.7 → pycaption-2.3.9}/README.rst +0 -0
- {pycaption-2.3.7 → pycaption-2.3.9}/pycaption/__init__.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.9}/pycaption/base.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.9}/pycaption/dfxp/__init__.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.9}/pycaption/dfxp/constants.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.9}/pycaption/dfxp/extras.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.9}/pycaption/dfxp/reader.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.9}/pycaption/exceptions.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.9}/pycaption/microdvd.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.9}/pycaption/sami/__init__.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.9}/pycaption/sami/constants.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.9}/pycaption/sami/writer.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.9}/pycaption/scc/__init__.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.9}/pycaption/scc/constants.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.9}/pycaption/scc/translator.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.9}/pycaption/scc/writer.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.9}/pycaption/transcript.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.9}/pycaption/utils.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.9}/pycaption/webvtt/__init__.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.9}/pycaption.egg-info/SOURCES.txt +0 -0
- {pycaption-2.3.7 → pycaption-2.3.9}/pycaption.egg-info/dependency_links.txt +0 -0
- {pycaption-2.3.7 → pycaption-2.3.9}/pycaption.egg-info/requires.txt +0 -0
- {pycaption-2.3.7 → pycaption-2.3.9}/pycaption.egg-info/top_level.txt +0 -0
- {pycaption-2.3.7 → pycaption-2.3.9}/setup.cfg +0 -0
- {pycaption-2.3.7 → pycaption-2.3.9}/tests/__init__.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.9}/tests/fixtures/__init__.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.9}/tests/fixtures/dfxp.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.9}/tests/fixtures/microdvd.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.9}/tests/fixtures/sami.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.9}/tests/fixtures/srt.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.9}/tests/fixtures/translated_scc.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.9}/tests/fixtures/webvtt.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.9}/tests/mixins.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.9}/tests/test_bytes_input.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.9}/tests/test_dfxp_conversion.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.9}/tests/test_dfxp_extras.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.9}/tests/test_double_encoding.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.9}/tests/test_functions.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.9}/tests/test_geometry.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.9}/tests/test_microdvd.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.9}/tests/test_microdvd_conversion.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.9}/tests/test_sami_conversion.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.9}/tests/test_scc_translator.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.9}/tests/test_scc_writer.py +0 -0
- {pycaption-2.3.7 → pycaption-2.3.9}/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):
|
|
@@ -174,6 +174,10 @@ class SAMIParser(HTMLParser):
|
|
|
174
174
|
:rtype: str | None
|
|
175
175
|
"""
|
|
176
176
|
for attr, value in attrs:
|
|
177
|
+
if value is None:
|
|
178
|
+
# A valueless attribute (e.g. a bare ``class``) carries no
|
|
179
|
+
# language information; skip it rather than crashing.
|
|
180
|
+
continue
|
|
177
181
|
if attr.lower() == "lang":
|
|
178
182
|
return value[:2]
|
|
179
183
|
if attr.lower() == "class":
|
|
@@ -142,7 +142,13 @@ class SAMIReader(BaseReader):
|
|
|
142
142
|
raise CaptionReadTimingError(
|
|
143
143
|
f"Missing start time on the following line: {p.parent}."
|
|
144
144
|
)
|
|
145
|
-
|
|
145
|
+
try:
|
|
146
|
+
milliseconds = int(float(start_str))
|
|
147
|
+
except ValueError:
|
|
148
|
+
raise CaptionReadTimingError(
|
|
149
|
+
f"Invalid start time {start_str!r} on the following "
|
|
150
|
+
f"line: {p.parent}."
|
|
151
|
+
)
|
|
146
152
|
start = milliseconds * 1000
|
|
147
153
|
|
|
148
154
|
self._backfill_end_times(captions, start)
|
|
@@ -263,7 +269,7 @@ class SAMIReader(BaseReader):
|
|
|
263
269
|
for style in styles:
|
|
264
270
|
style = style.split(":")
|
|
265
271
|
if len(style) == 2:
|
|
266
|
-
css_property, value = style
|
|
272
|
+
css_property, value = style[0].strip(), style[1]
|
|
267
273
|
else:
|
|
268
274
|
continue
|
|
269
275
|
if css_property == "text-align":
|
|
@@ -100,6 +100,7 @@ from .constants import (
|
|
|
100
100
|
SPECIAL_CHARS,
|
|
101
101
|
)
|
|
102
102
|
from .specialized_collections import (
|
|
103
|
+
PUNCTUATION_PREFIXES,
|
|
103
104
|
CaptionCreator,
|
|
104
105
|
InstructionNodeCreator,
|
|
105
106
|
NotifyingDict,
|
|
@@ -302,23 +303,28 @@ class SCCReader(BaseReader):
|
|
|
302
303
|
word = word.strip()
|
|
303
304
|
if len(word) == 4:
|
|
304
305
|
# Look ahead for the next command, skipping the duplicate
|
|
305
|
-
# that SCC uses for error-correction (same word repeated)
|
|
306
|
+
# that SCC uses for error-correction (same word repeated), to
|
|
307
|
+
# tell whether it decodes to punctuation.
|
|
306
308
|
next_idx = idx + 1
|
|
307
309
|
if next_idx < len(word_list) and word_list[next_idx].strip() == word:
|
|
308
310
|
next_idx += 1
|
|
309
311
|
next_command = (
|
|
310
312
|
word_list[next_idx] if next_idx < len(word_list) else None
|
|
311
313
|
)
|
|
312
|
-
|
|
314
|
+
next_is_punctuation = (
|
|
315
|
+
next_command is not None
|
|
316
|
+
and next_command[:2] in PUNCTUATION_PREFIXES
|
|
317
|
+
)
|
|
318
|
+
self._translate_word(word=word, next_is_punctuation=next_is_punctuation)
|
|
313
319
|
|
|
314
|
-
def _translate_word(self, word,
|
|
320
|
+
def _translate_word(self, word, next_is_punctuation=False):
|
|
315
321
|
"""Dispatch a single 4-char hex word as command, special char, or text."""
|
|
316
322
|
if self._handle_double_command(word):
|
|
317
323
|
# count frames for timing
|
|
318
324
|
self.time_translator._frames += 1
|
|
319
325
|
return
|
|
320
326
|
if word in COMMANDS or self._is_pac_command(word):
|
|
321
|
-
self._translate_command(word=word,
|
|
327
|
+
self._translate_command(word=word, next_is_punctuation=next_is_punctuation)
|
|
322
328
|
|
|
323
329
|
# second, check if word is a special character
|
|
324
330
|
elif word in SPECIAL_CHARS:
|
|
@@ -392,7 +398,7 @@ class SCCReader(BaseReader):
|
|
|
392
398
|
|
|
393
399
|
_ROLL_UP_DEPTH = {"9425": 2, "9426": 3, "94a7": 4}
|
|
394
400
|
|
|
395
|
-
def _translate_command(self, word,
|
|
401
|
+
def _translate_command(self, word, next_is_punctuation=False):
|
|
396
402
|
"""Route a CEA-608 control command to the appropriate handler."""
|
|
397
403
|
if word == "9420":
|
|
398
404
|
self._cmd_pop_on()
|
|
@@ -409,7 +415,11 @@ class SCCReader(BaseReader):
|
|
|
409
415
|
elif word == "942c":
|
|
410
416
|
self._cmd_erase_displayed()
|
|
411
417
|
else:
|
|
412
|
-
self.buffer.interpret_command(
|
|
418
|
+
self.buffer.interpret_command(
|
|
419
|
+
command=word,
|
|
420
|
+
next_is_punctuation=next_is_punctuation,
|
|
421
|
+
is_paint_on=self.buffer_dict.active_key == "paint",
|
|
422
|
+
)
|
|
413
423
|
|
|
414
424
|
def _cmd_pop_on(self):
|
|
415
425
|
"""Handle Resume Caption Loading [RCL] - switch to pop-on mode."""
|
|
@@ -32,7 +32,7 @@ from .constants import (
|
|
|
32
32
|
PopOnCue = collections.namedtuple("PopOnCue", "buffer, start, end")
|
|
33
33
|
|
|
34
34
|
# First two hex chars of SCC codes that produce punctuation ['.', '!', '?', ',']
|
|
35
|
-
|
|
35
|
+
PUNCTUATION_PREFIXES = frozenset(["ae", "a1", "bf", "2c"])
|
|
36
36
|
|
|
37
37
|
|
|
38
38
|
class PreCaption:
|
|
@@ -412,7 +412,7 @@ class InstructionNodeCreator:
|
|
|
412
412
|
# only remaining possibility is plain text
|
|
413
413
|
return "plaintext"
|
|
414
414
|
|
|
415
|
-
def interpret_command(self, command,
|
|
415
|
+
def interpret_command(self, command, next_is_punctuation=False, is_paint_on=False):
|
|
416
416
|
"""Given a command determines whether to turn italics on or off,
|
|
417
417
|
or to set the positioning
|
|
418
418
|
|
|
@@ -420,9 +420,15 @@ class InstructionNodeCreator:
|
|
|
420
420
|
|
|
421
421
|
:type command: str
|
|
422
422
|
or a PAC_TAB_OFFSET_COMMANDS
|
|
423
|
-
:type
|
|
423
|
+
:type next_is_punctuation: bool
|
|
424
|
+
:param next_is_punctuation: whether the command right after this one
|
|
425
|
+
decodes to punctuation (comma, period, etc.)
|
|
426
|
+
:type is_paint_on: bool
|
|
427
|
+
:param is_paint_on: whether this command is being interpreted while
|
|
428
|
+
in paint-on mode, where a row+1 jump paired with a large column
|
|
429
|
+
jump indicates an unrelated, independently-positioned region
|
|
424
430
|
"""
|
|
425
|
-
self._update_positioning(command)
|
|
431
|
+
self._update_positioning(command, is_paint_on)
|
|
426
432
|
|
|
427
433
|
if command == "94a1":
|
|
428
434
|
self.handle_backspace("94a1")
|
|
@@ -434,7 +440,7 @@ class InstructionNodeCreator:
|
|
|
434
440
|
self._handle_style_command(command)
|
|
435
441
|
|
|
436
442
|
if command in MID_ROW_CODES and command not in PAC_TAB_OFFSET_COMMANDS:
|
|
437
|
-
self._handle_mid_row_spacing(
|
|
443
|
+
self._handle_mid_row_spacing(next_is_punctuation)
|
|
438
444
|
|
|
439
445
|
def _handle_background_color(self):
|
|
440
446
|
"""Strip trailing space before a background color code (CEA-608 rule)."""
|
|
@@ -460,9 +466,7 @@ class InstructionNodeCreator:
|
|
|
460
466
|
if self.last_style is not None and self.last_style != "italics off":
|
|
461
467
|
return
|
|
462
468
|
self._emit_pending_breaks(position)
|
|
463
|
-
self._collection.append(
|
|
464
|
-
_InstructionNode.create_italics_style(position)
|
|
465
|
-
)
|
|
469
|
+
self._collection.append(_InstructionNode.create_italics_style(position))
|
|
466
470
|
self.last_style = "italics on"
|
|
467
471
|
|
|
468
472
|
def _close_italics(self, position):
|
|
@@ -482,24 +486,29 @@ class InstructionNodeCreator:
|
|
|
482
486
|
if not self._position_tracer.is_linebreak_required():
|
|
483
487
|
return
|
|
484
488
|
for _ in range(self._position_tracer._breaks_required):
|
|
485
|
-
self._collection.append(
|
|
486
|
-
_InstructionNode.create_break(position=position)
|
|
487
|
-
)
|
|
489
|
+
self._collection.append(_InstructionNode.create_break(position=position))
|
|
488
490
|
self._position_tracer.acknowledge_linebreak_consumed()
|
|
489
491
|
|
|
490
|
-
def _handle_mid_row_spacing(self,
|
|
492
|
+
def _handle_mid_row_spacing(self, next_is_punctuation):
|
|
491
493
|
"""Insert spacing around mid-row code style transitions."""
|
|
492
|
-
|
|
494
|
+
if self._position_tracer.is_repositioning_required():
|
|
495
|
+
# A repositioning is already pending with no text written at the
|
|
496
|
+
# current position — that position is about to be abandoned, so
|
|
497
|
+
# padding it with a decorative space would wrongly consume the
|
|
498
|
+
# pending repositioning before the real content arrives.
|
|
499
|
+
return
|
|
493
500
|
prev_text_node = self.get_previous_text_node()
|
|
494
501
|
if not prev_text_node:
|
|
495
502
|
return
|
|
496
503
|
prev_node_is_break = any(
|
|
497
504
|
x.is_explicit_break()
|
|
498
|
-
for x in self._collection[self._collection.index(prev_text_node):]
|
|
505
|
+
for x in self._collection[self._collection.index(prev_text_node) :]
|
|
499
506
|
)
|
|
500
|
-
if (
|
|
501
|
-
|
|
502
|
-
|
|
507
|
+
if (
|
|
508
|
+
prev_node_is_break
|
|
509
|
+
or prev_text_node.text[-1].isspace()
|
|
510
|
+
or next_is_punctuation
|
|
511
|
+
):
|
|
503
512
|
return
|
|
504
513
|
|
|
505
514
|
if self.last_style == "italics off":
|
|
@@ -507,10 +516,11 @@ class InstructionNodeCreator:
|
|
|
507
516
|
else:
|
|
508
517
|
prev_text_node.text = prev_text_node.text + " "
|
|
509
518
|
|
|
510
|
-
def _update_positioning(self, command):
|
|
519
|
+
def _update_positioning(self, command, is_paint_on=False):
|
|
511
520
|
"""Sets the positioning information to use for the next nodes
|
|
512
521
|
|
|
513
522
|
:type command: str
|
|
523
|
+
:type is_paint_on: bool
|
|
514
524
|
"""
|
|
515
525
|
is_offset = False
|
|
516
526
|
if command in PAC_TAB_OFFSET_COMMANDS:
|
|
@@ -529,7 +539,9 @@ class InstructionNodeCreator:
|
|
|
529
539
|
offset_after_break = is_offset and self.has_break_before(self._collection)
|
|
530
540
|
if not offset_after_break:
|
|
531
541
|
# Tab offsets after line breaks will be ignored to avoid repositioning
|
|
532
|
-
self._position_tracer.update_positioning(
|
|
542
|
+
self._position_tracer.update_positioning(
|
|
543
|
+
positioning, column_jump_forces_reposition=is_paint_on
|
|
544
|
+
)
|
|
533
545
|
|
|
534
546
|
def __iter__(self):
|
|
535
547
|
return iter(_format_italics(self._collection))
|
|
@@ -25,17 +25,39 @@ class _PositioningTracker:
|
|
|
25
25
|
# next positioning is actually a Tab Offset
|
|
26
26
|
self._last_column = None
|
|
27
27
|
|
|
28
|
-
def update_positioning(self, positioning):
|
|
28
|
+
def update_positioning(self, positioning, column_jump_forces_reposition=False):
|
|
29
29
|
"""Being notified of a position change, updates the internal state,
|
|
30
30
|
to as to be able to tell if it was a trivial change (a simple line
|
|
31
31
|
break) or not.
|
|
32
32
|
|
|
33
33
|
Strategy:
|
|
34
|
-
-
|
|
35
|
-
|
|
34
|
+
- A jump to the very next row (row + 1): Use a single line break —
|
|
35
|
+
this is simple text wrapping onto the next line. Exception: if
|
|
36
|
+
paired with a column jump bigger than a tab offset in paint-on
|
|
37
|
+
mode (see ``column_jump_forces_reposition``), it's treated as a
|
|
38
|
+
repositioning instead, since paint-on is the only mode that can
|
|
39
|
+
display independent, simultaneously-timed regions one row apart.
|
|
40
|
+
- Any other row jump (a skipped row, or a jump backwards): Use
|
|
41
|
+
repositioning (creates new cue). A skipped row is not an
|
|
42
|
+
intentional blank line to preserve — CEA-608 PACs only declare an
|
|
43
|
+
absolute row position; nothing in the spec says a gap between two
|
|
44
|
+
rows must be rendered as a blank line downstream, and WebVTT/HTML
|
|
45
|
+
renderers draw a real, often background-filled line for each
|
|
46
|
+
break, which previously produced a visible black bar between two
|
|
47
|
+
lines of text that were never meant to have a gap between them.
|
|
36
48
|
|
|
37
49
|
:type positioning: tuple[int]
|
|
38
50
|
:param positioning: a tuple (row, col)
|
|
51
|
+
|
|
52
|
+
:type column_jump_forces_reposition: bool
|
|
53
|
+
:param column_jump_forces_reposition: when True, a row+1 jump
|
|
54
|
+
paired with a column jump larger than a tab offset (>3 columns)
|
|
55
|
+
is treated as a repositioning rather than a break. Only
|
|
56
|
+
paint-on mode can display independent, simultaneously-timed
|
|
57
|
+
regions at unrelated columns, so callers should only set this
|
|
58
|
+
for paint-on buffers — pop-on and roll-up buffers legitimately
|
|
59
|
+
use large column shifts between buffered/wrapped lines of the
|
|
60
|
+
same cue.
|
|
39
61
|
"""
|
|
40
62
|
current = self._positions[-1]
|
|
41
63
|
|
|
@@ -51,24 +73,31 @@ class _PositioningTracker:
|
|
|
51
73
|
new_row, new_col = positioning
|
|
52
74
|
is_tab_offset = new_row == row and col + 1 <= new_col <= col + 3
|
|
53
75
|
|
|
54
|
-
# Threshold for when to use breaks vs repositioning
|
|
55
|
-
# Jumps of 4+ rows will trigger repositioning instead of adding breaks
|
|
56
|
-
max_breaks_threshold = 3
|
|
57
|
-
|
|
58
76
|
# Handle row jumps
|
|
59
77
|
if new_row > row:
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
78
|
+
is_large_column_jump = (
|
|
79
|
+
column_jump_forces_reposition and abs(new_col - col) > 3
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# A jump to the very next row: use a line break. But if a
|
|
83
|
+
# repositioning was already pending and unconsumed (no text was
|
|
84
|
+
# ever written at the current position), this row jump is
|
|
85
|
+
# continuing that same unresolved position change, not wrapping
|
|
86
|
+
# text — so it must stay a repositioning rather than become a
|
|
87
|
+
# break. Likewise, in paint-on mode, pairing the row+1 jump
|
|
88
|
+
# with a column jump bigger than a tab offset means the new PAC
|
|
89
|
+
# almost certainly targets an unrelated, independently-positioned
|
|
90
|
+
# region rather than wrapping the current text.
|
|
91
|
+
if (
|
|
92
|
+
new_row == row + 1
|
|
93
|
+
and not self._repositioning_required
|
|
94
|
+
and not is_large_column_jump
|
|
95
|
+
):
|
|
64
96
|
self._positions.append((new_row, col))
|
|
65
|
-
|
|
66
|
-
# Row N -> N+1: 1 break
|
|
67
|
-
# Row N -> N+2: 2 breaks (preserves 1 blank line)
|
|
68
|
-
# Row N -> N+3: 3 breaks (preserves 2 blank lines)
|
|
69
|
-
self._breaks_required = row_diff
|
|
97
|
+
self._breaks_required = 1
|
|
70
98
|
self._last_column = new_col
|
|
71
|
-
#
|
|
99
|
+
# A skipped row, a jump backwards, or a row+1 jump with a large
|
|
100
|
+
# paint-on column jump: use repositioning (new cue)
|
|
72
101
|
else:
|
|
73
102
|
# Reset position - this triggers repositioning
|
|
74
103
|
self._positions = [positioning]
|
|
@@ -165,7 +194,7 @@ class DefaultProvidingPositionTracker(_PositioningTracker):
|
|
|
165
194
|
except CaptionReadSyntaxError:
|
|
166
195
|
return self.default
|
|
167
196
|
|
|
168
|
-
def update_positioning(self, positioning):
|
|
197
|
+
def update_positioning(self, positioning, column_jump_forces_reposition=False):
|
|
169
198
|
"""If called, sets this positioning as the default, then delegates
|
|
170
199
|
to the super class.
|
|
171
200
|
|
|
@@ -175,4 +204,4 @@ class DefaultProvidingPositionTracker(_PositioningTracker):
|
|
|
175
204
|
if positioning:
|
|
176
205
|
self.default = positioning
|
|
177
206
|
|
|
178
|
-
super().update_positioning(positioning)
|
|
207
|
+
super().update_positioning(positioning, column_jump_forces_reposition)
|
|
@@ -6,7 +6,7 @@ from .base import (
|
|
|
6
6
|
BaseReader, BaseWriter, Caption, CaptionList, CaptionNode, CaptionSet,
|
|
7
7
|
merge_caption_list,
|
|
8
8
|
)
|
|
9
|
-
from .exceptions import CaptionReadNoCaptions
|
|
9
|
+
from .exceptions import CaptionReadNoCaptions, CaptionReadSyntaxError
|
|
10
10
|
from .geometry import HorizontalAlignmentEnum
|
|
11
11
|
|
|
12
12
|
|
|
@@ -21,6 +21,8 @@ class SRTReader(BaseReader):
|
|
|
21
21
|
"""
|
|
22
22
|
content = self._decode_content(content)
|
|
23
23
|
lines = content.splitlines()
|
|
24
|
+
if len(lines) < 2:
|
|
25
|
+
return False
|
|
24
26
|
if lines[0].isdigit() and "-->" in lines[1]:
|
|
25
27
|
return True
|
|
26
28
|
else:
|
|
@@ -47,6 +49,11 @@ class SRTReader(BaseReader):
|
|
|
47
49
|
|
|
48
50
|
end_line = self._find_text_line(start_line, lines)
|
|
49
51
|
|
|
52
|
+
if start_line + 1 >= len(lines) or "-->" not in lines[start_line + 1]:
|
|
53
|
+
raise CaptionReadSyntaxError(
|
|
54
|
+
"Missing or malformed timing line after caption number "
|
|
55
|
+
f"{lines[start_line]!r}."
|
|
56
|
+
)
|
|
50
57
|
timing = lines[start_line + 1].split("-->")
|
|
51
58
|
start = self._srttomicro(timing[0].strip(" \r\n"))
|
|
52
59
|
end = self._srttomicro(timing[1].strip(" \r\n"))
|
|
@@ -81,15 +88,18 @@ class SRTReader(BaseReader):
|
|
|
81
88
|
def _srttomicro(stamp):
|
|
82
89
|
"""Convert an SRT timestamp (HH:MM:SS,mmm) to microseconds."""
|
|
83
90
|
timesplit = stamp.split(":")
|
|
84
|
-
|
|
85
|
-
timesplit[2]
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
91
|
+
try:
|
|
92
|
+
if "," not in timesplit[2]:
|
|
93
|
+
timesplit[2] += ",000"
|
|
94
|
+
secsplit = timesplit[2].split(",")
|
|
95
|
+
microseconds = (
|
|
96
|
+
int(timesplit[0]) * 3600000000
|
|
97
|
+
+ int(timesplit[1]) * 60000000
|
|
98
|
+
+ int(secsplit[0]) * 1000000
|
|
99
|
+
+ int(secsplit[1]) * 1000
|
|
100
|
+
)
|
|
101
|
+
except (IndexError, ValueError):
|
|
102
|
+
raise CaptionReadSyntaxError(f"Invalid SRT timestamp: {stamp!r}.")
|
|
93
103
|
|
|
94
104
|
return microseconds
|
|
95
105
|
|
|
@@ -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."""
|