Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 95 additions & 17 deletions src/ttml2ssa.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,6 @@ class Ttml2Ssa(object):
'FILM2PAL' : 24/25
}

TOP_MARKER = '{\\an8}'

def __init__(self, shift=0, source_fps=23.976, scale_factor=1, subtitle_language=None):
self.shift = shift
self.source_fps = source_fps
Expand Down Expand Up @@ -72,9 +70,11 @@ def __init__(self, shift=0, source_fps=23.976, scale_factor=1, subtitle_language
self._top_regions_ids = []

self._allowed_style_attrs = (
'backgroundColor',
'color',
'fontStyle',
'fontWeight',
'textAlign',
)

## This variable stores the language ID from the xml file.
Expand Down Expand Up @@ -203,8 +203,8 @@ def extract_rate(s):

# Grab <style>s
# https://www.w3.org/TR/ttml1/#styling-attribute-vocabulary
for styles_container in ttml_dom.getElementsByTagName('styling'):
for style in styles_container.getElementsByTagName('style'):
for styles_container in ttml_dom.getElementsByTagNameNS('*', 'styling'):
for style in styles_container.getElementsByTagNameNS('*', 'style'):
style_id = getattr(
style.attributes.get('xml:id', {}), 'value', None)
if not style_id:
Expand All @@ -214,16 +214,16 @@ def extract_rate(s):
self._italic_style_ids.append(style_id)

# Grab top regions
for layout_container in ttml_dom.getElementsByTagName('layout'):
for region in layout_container.getElementsByTagName('region'):
for layout_container in ttml_dom.getElementsByTagNameNS('*', 'layout'):
for region in layout_container.getElementsByTagNameNS('*', 'region'):
region_id = getattr(
region.attributes.get('xml:id', {}), 'value', None)
if region_id:
# Case 1: displayAlign is in layout -> region
if region.getAttribute('tts:displayAlign') == 'before':
self._top_regions_ids.append(region_id)
# Case 2: displayAlign is in layout -> region -> style
for style in region.getElementsByTagName('style'):
for style in region.getElementsByTagNameNS('*', 'style'):
if style.getAttribute('tts:displayAlign') == 'before':
self._top_regions_ids.append(region_id)

Expand Down Expand Up @@ -287,13 +287,15 @@ def _get_tt_style_attrs(self, node, in_head=False):
return style


def _extract_dialogue(self, nodes, styles=[]):
def _extract_dialogue(self, nodes, styles=[], ass_styles=[]):
"""Extract text content and styling attributes from <p> elements.

Args:
nodes (xml.dom.minidom.Node): List of <p> elements
styles (list): List of style signifiers that should be
applied to each node
ass_styles (list): List of ASS style signifiers that should be
applied to each node

Return:
List of SRT paragraphs (strings)
Expand All @@ -303,6 +305,7 @@ def _extract_dialogue(self, nodes, styles=[]):

for node in nodes:
_styles = []
_ass_styles = []

if node.nodeType == node.TEXT_NODE:
format_str = '{}'
Expand All @@ -317,6 +320,8 @@ def _extract_dialogue(self, nodes, styles=[]):
ot='<{}>'.format(style),
f=format_str)

if ass_styles:
dialogue.append('{' + ''.join(ass_styles) + '}')
dialogue.append(format_str.format(text))

elif node.localName == 'br':
Expand All @@ -330,9 +335,23 @@ def _extract_dialogue(self, nodes, styles=[]):
assoc_italic = style_attrs['style_id'] in self._italic_style_ids
if inline_italic or assoc_italic or node.parentNode.getAttribute('style') == 'AmazonDefaultStyle':
_styles.append('i')
inline_color = self._styles[style_attrs['style_id']]['color']
if inline_color != '':
rgba = Ttml2Ssa._hex_to_rgba(inline_color)
inline_color = Ttml2Ssa._rgba_to_bgr_asshex(rgba)
inline_color_alpha = Ttml2Ssa._rgba_to_alpha_asshex(rgba)
_ass_styles.append('\\1c' + inline_color)
_ass_styles.append('\\1a' + inline_color_alpha)
inline_bcolor = self._styles[style_attrs['style_id']]['background_color']
if inline_bcolor != '':
rgba = Ttml2Ssa._hex_to_rgba(inline_bcolor)
inline_bcolor = Ttml2Ssa._rgba_to_bgr_asshex(rgba)
inline_bcolor_alpha = Ttml2Ssa._rgba_to_alpha_asshex(rgba)
_ass_styles.append('\\3c' + inline_bcolor)
_ass_styles.append('\\3a' + inline_bcolor_alpha)

if node.hasChildNodes():
dialogue += self._extract_dialogue(node.childNodes, _styles)
dialogue += self._extract_dialogue(node.childNodes, _styles, _ass_styles)

return ''.join(dialogue)

Expand All @@ -347,15 +366,32 @@ def _process_parag(self, paragraph):
begin in ms,
end in ms,
text content in Subrip (SRT) format,
position (top or bottom) where the text should appear
position (1..9) where the text should appear
"""

begin = paragraph.attributes['begin'].value
end = paragraph.attributes['end'].value

style = None
if 'style' in paragraph.attributes:
style = paragraph.attributes['style'].value

ms_begin = self._tc.timeexpr_to_ms(begin)
ms_end = self._tc.timeexpr_to_ms(end)

alignment = None
if style and 'text_align' in self._styles[style]:
alignment = self._styles[style]['text_align']

if alignment and alignment == 'center':
alignment = 0
elif alignment and alignment == 'left':
alignment = -1
elif alignment and alignment == 'right':
alignment = 1
else:
alignment = 0

dialogue = self._extract_dialogue(paragraph.childNodes)

# Trim lines and remove empty lines
Expand All @@ -367,7 +403,10 @@ def _process_parag(self, paragraph):
new_text += line
dialogue = new_text

position = 'top' if paragraph.getAttribute('region') in self._top_regions_ids else 'bottom'
# Region information is stored in a numpad layout with numbers 1 to 9
# 1 is the lower left and 9 is the upper right corner
position = 8 if paragraph.getAttribute('region') in self._top_regions_ids else 2
position += alignment

return ms_begin, ms_end, dialogue, position

Expand Down Expand Up @@ -420,7 +459,7 @@ def unescape_text(text):
entry = {}
entry['ms_begin'] = self._tc.timeexpr_to_ms(time1)
entry['ms_end'] = self._tc.timeexpr_to_ms(time2)
entry['position'] = 'top' if m.group('pos') and float(m.group('pos')) < 50 else 'bottom'
entry['position'] = 8 if m.group('pos') and float(m.group('pos')) < 50 else 2
text = ""
while i < len(lines):
line = lines[i].strip()
Expand Down Expand Up @@ -453,8 +492,10 @@ def generate_srt(self):
# Remove <c> </c> tags
text = re.sub('</??c.*?>', '', text)

if self.allow_top_pos and entry['position'] == 'top':
text = Ttml2Ssa.TOP_MARKER + text
position = entry['position']
if not self.allow_top_pos and entry['position'] > 6:
position = position - 6
text = '{\\an' + str(position) + '}' + text

res += srt_format_str.format(entry_count, \
self._tc.ms_to_subrip(entry['ms_begin']), \
Expand All @@ -479,7 +520,7 @@ def generate_vtt(self):
text = re.sub('</??c.*?>', '', text)

pos_str = 'line:90%,end'
if self.allow_top_pos and entry['position'] == 'top':
if self.allow_top_pos and entry['position'] > 6:
pos_str = 'line:10%,start'

res += vtt_format_str.format(self._tc.ms_to_subrip(entry['ms_begin']).replace(',','.'), \
Expand Down Expand Up @@ -516,8 +557,10 @@ def fix_timestamps_separation(entries, timestamp_min_sep):
('<.*?>', '')]:
text = re.sub(tag[0], tag[1], text)

if self.allow_top_pos and entry['position'] == 'top':
text = Ttml2Ssa.TOP_MARKER + text
position = entry['position']
if not self.allow_top_pos and entry['position'] > 6:
position = position - 6
text = '{\\an' + str(position) + '}' + text

res += ssa_format_str.format(self._tc.ms_to_ssa(entry['ms_begin']), self._tc.ms_to_ssa(entry['ms_end']), text)
return res
Expand Down Expand Up @@ -737,6 +780,41 @@ def string_to_color(self, text):
hex_number = "&H" + format(number, '08x').upper()
return hex_number

@staticmethod
def _hex_to_rgba(value: str) -> tuple[int, int, int, int]:
"""Convert a hex RGB(A) string into a RGBA tuple.

Possible string formats:
* ``#RRGGBBAA``
* ``#RRGGBB``
* ``RRGGBBAA``
* ``RRGGBB``
"""
value = value.lstrip('#')
lv = len(value)

if lv == 6: # RGB
r, g, b = tuple(int(value[i:i + 2], base=16) for i in range(0, lv, 2))
return (r, g, b, 255) # Default alpha to 255 (fully opaque)
elif lv == 8: # RGBA
r, g, b, a = tuple(int(value[i:i + 2], base=16) for i in range(0, lv, 2))
return (r, g, b, a)
else:
raise ValueError("Invalid hex color format. Must be 6 or 8 characters long.")

@staticmethod
def _rgba_to_bgr_asshex(rgba: tuple[int, int, int, int]) -> str:
"""Convert RGBA tuple to ASS inline BGR format: ``&HBBGGRR&``"""
return f'&H{rgba[2]:02X}{rgba[1]:02X}{rgba[0]:02X}&'

@staticmethod
def _rgba_to_alpha_asshex(rgba: tuple[int, int, int, int]) -> str:
"""Convert RGBA tuple to ASS inline alpha format: ``&HAA&``"""

# ASS transparency values are inverse to the usual values
# with 255 being fully transparent and 0 being fully opaque.
return f'&H{255 - rgba[3]:02X}&'

@staticmethod
def _snake_to_camel(s):
camel = ''
Expand Down