1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176
|
import re
import warnings
from typing import Optional, Dict, Any, ClassVar, FrozenSet
import dataclasses
from .common import IntOrFloat
from .time import ms_to_str, make_time
@dataclasses.dataclass(repr=False, eq=False, order=False)
class SSAEvent:
"""
A SubStation Event, ie. one subtitle.
In SubStation, each subtitle consists of multiple "fields" like Start, End and Text.
These are exposed as attributes (note that they are lowercase; see :attr:`SSAEvent.FIELDS` for a list).
Additionaly, there are some convenience properties like :attr:`SSAEvent.plaintext` or :attr:`SSAEvent.duration`.
This class defines an ordering with respect to (start, end) timestamps.
.. tip :: Use :func:`pysubs2.make_time()` to get times in milliseconds.
Example::
>>> ev = SSAEvent(start=make_time(s=1), end=make_time(s=2.5), text="Hello World!")
"""
OVERRIDE_SEQUENCE: ClassVar = re.compile(r"{[^}]*}")
start: int = 0 #: Subtitle start time (in milliseconds)
end: int = 10000 #: Subtitle end time (in milliseconds)
text: str = "" #: Text of subtitle (with SubStation override tags)
marked: bool = False #: (SSA only)
layer: int = 0 #: Layer number, 0 is the lowest layer (ASS only)
style: str = "Default" #: Style name
name: str = "" #: Actor name
marginl: int = 0 #: Left margin
marginr: int = 0 #: Right margin
marginv: int = 0 #: Vertical margin
effect: str = "" #: Line effect
type: str = "Dialogue" #: Line type (Dialogue/Comment)
@property
def FIELDS(self) -> FrozenSet[str]:
"""All fields in SSAEvent."""
warnings.warn("Deprecated in 1.2.0 - it's a dataclass now", DeprecationWarning)
return frozenset(field.name for field in dataclasses.fields(self))
@property
def duration(self) -> IntOrFloat:
"""
Subtitle duration in milliseconds (read/write property).
Writing to this property adjusts :attr:`SSAEvent.end`.
Setting negative durations raises :exc:`ValueError`.
"""
return self.end - self.start
@duration.setter
def duration(self, ms: int) -> None:
if ms >= 0:
self.end = self.start + ms
else:
raise ValueError("Subtitle duration cannot be negative")
@property
def is_comment(self) -> bool:
"""
When true, the subtitle is a comment, ie. not visible (read/write property).
Setting this property is equivalent to changing
:attr:`SSAEvent.type` to ``"Dialogue"`` or ``"Comment"``.
"""
return self.type == "Comment"
@is_comment.setter
def is_comment(self, value: bool) -> None:
if value:
self.type = "Comment"
else:
self.type = "Dialogue"
@property
def is_drawing(self) -> bool:
"""Returns True if line is SSA drawing tag (ie. not text)"""
from .formats.substation import parse_tags
return any(sty.drawing for _, sty in parse_tags(self.text))
@property
def is_text(self) -> bool:
"""
Returns False for SSA drawings and comment lines, True otherwise
In general, for non-SSA formats these events should be ignored.
"""
return not (self.is_comment or self.is_drawing)
@property
def plaintext(self) -> str:
"""
Subtitle text as multi-line string with no tags (read/write property).
Writing to this property replaces :attr:`SSAEvent.text` with given plain
text. Newlines are converted to ``\\N`` tags.
"""
text = self.text
text = self.OVERRIDE_SEQUENCE.sub("", text)
text = text.replace(r"\h", " ")
text = text.replace(r"\n", "\n")
text = text.replace(r"\N", "\n")
return text
@plaintext.setter
def plaintext(self, text: str) -> None:
self.text = text.replace("\n", r"\N")
def shift(self, h: IntOrFloat = 0, m: IntOrFloat = 0, s: IntOrFloat = 0, ms: IntOrFloat = 0,
frames: Optional[int] = None, fps: Optional[float] = None) -> None:
"""
Shift start and end times.
See :meth:`SSAFile.shift()` for full description.
"""
delta = make_time(h=h, m=m, s=s, ms=ms, frames=frames, fps=fps)
self.start += delta
self.end += delta
def copy(self) -> "SSAEvent":
"""Return a copy of the SSAEvent."""
return SSAEvent(**self.as_dict())
def as_dict(self) -> Dict[str, Any]:
# dataclasses.asdict() would recursively dictify Color objects, which we don't want
return {field.name: getattr(self, field.name) for field in dataclasses.fields(self)}
def equals(self, other: "SSAEvent") -> bool:
"""Field-based equality for SSAEvents."""
if isinstance(other, SSAEvent):
return self.as_dict() == other.as_dict()
else:
raise TypeError("Cannot compare to non-SSAEvent object")
def __eq__(self, other: object) -> bool:
# XXX document this
if not isinstance(other, SSAEvent):
return NotImplemented
return self.start == other.start and self.end == other.end
def __ne__(self, other: object) -> bool:
if not isinstance(other, SSAEvent):
return NotImplemented
return self.start != other.start or self.end != other.end
def __lt__(self, other: object) -> bool:
if not isinstance(other, SSAEvent):
return NotImplemented
return (self.start, self.end) < (other.start, other.end)
def __le__(self, other: object) -> bool:
if not isinstance(other, SSAEvent):
return NotImplemented
return (self.start, self.end) <= (other.start, other.end)
def __gt__(self, other: object) -> bool:
if not isinstance(other, SSAEvent):
return NotImplemented
return (self.start, self.end) > (other.start, other.end)
def __ge__(self, other: object) -> bool:
if not isinstance(other, SSAEvent):
return NotImplemented
return (self.start, self.end) >= (other.start, other.end)
def __repr__(self) -> str:
return f"<SSAEvent type={self.type} start={ms_to_str(self.start)} end={ms_to_str(self.end)} text={self.text!r}>"
|