# SPDX-FileCopyrightText: Copyright © 2026 BBC
#
# SPDX-License-Identifier: BSD-3-Clause
import re
from collections.abc import Callable
from dataclasses import dataclass, field
from xml.etree.ElementTree import Element
from .xmlUtils import make_qname, get_unqualified_name, \
get_namespace, xmlIdAttr
from .validationLogging.validationCodes import ValidationCode
from .validationLogging.validationResult import ValidationResult, ERROR
from .validationLogging.validationLogger import ValidationLogger
# import logging
import types
from typing import TypeVar
styling_ns_suffix = '#styling'
ebutts_ns = 'urn:ebu:tt:style'
itts_ns = 'http://www.w3.org/ns/ttml/profile/imsc1#styling'
ebutt_distribution_color_type_regex = re.compile(
r'^#(?P<r>[0-9a-fA-F]{2})'
r'(?P<g>[0-9a-fA-F]{2})'
r'(?P<b>[0-9a-fA-F]{2})'
r'(?P<a>[0-9a-fA-F]{2})?$')
two_percent_vals_regex = re.compile(
r'^(?P<x>[0]*((100(\.[0]+)?)|[\d]{1,2}(\.[\d]+)?))%'
r'[\s]+'
r'(?P<y>[0]*((100(\.[0]+)?)|[\d]{1,2}(\.[\d]+)?))%$')
percent_regex = \
re.compile(r'^(?P<percent>[0-9]+(\.[0-9]+)?)%$')
basis_regex = \
re.compile(r'^(?P<number>[0-9]+(\.[0-9]+)?)(?P<unit>[a-zA-Z%]*)$')
specified_type = TypeVar('specified', bound=str)
parent_type = TypeVar('parent', bound=str)
params_type = TypeVar('params', bound=dict[str, str])
[docs]
@dataclass
class StyleAttribute:
ns: str
nsIsRelative: bool
tag: str
appliesTo: list[str]
syntaxRegex: re.Pattern
defaultValue: str
computeValue: Callable[[specified_type, parent_type, params_type], str] = \
field(hash=False, compare=False)
fallbackComputeValue: Callable[
[specified_type, parent_type, params_type], str] = \
field(hash=False, compare=False)
def __post_init__(self):
# Bind the computeValue method to self so it can access
# object fields like syntaxRegex
self.computeValue = types.MethodType(self.computeValue, self)
self.fallbackComputeValue = \
types.MethodType(self.fallbackComputeValue, self)
[docs]
def validateValue(self, value: str):
syntax_match = self.syntaxRegex.match(value)
return syntax_match is not None
def _computeUninheritedAttribute(
self,
specified: str,
parent: str,
params: dict[str, str]
) -> str:
return specified if specified else self.defaultValue
def _computeSimpleInheritedAttribute(
self,
specified: str,
parent: str,
params: dict[str, str]
) -> str:
rv = self.defaultValue if not specified else specified
if parent and not specified:
rv = parent
# return parent if not specified else specified
return rv
def _getPercentRelativeSize(
specified: str,
basis: str
) -> str:
decoded_specified = percent_regex.match(specified)
if decoded_specified:
specified_float = float(decoded_specified.group('percent')) / 100
else:
raise ValueError(
'Specified value {} is not a valid percentage value'
.format(specified))
decoded_basis = basis_regex.match(basis)
if decoded_basis:
basis_float = float(decoded_basis.group('number'))
basis_unit = decoded_basis.group('unit')
else:
raise ValueError(
'Basis value {} is not a valid number'
.format(basis))
return '{:.6f}{}'.format(specified_float * basis_float, basis_unit)
def _getCellHeight(params: dict[str, str]) -> str:
unq_key = 'cellResolution'
value = 100*1/15 # from default "32 15" value
unit = 'rh'
cellResolutionParams = {
key: value for key, value in params.items()
if get_unqualified_name(key) == unq_key}
if len(cellResolutionParams) == 1:
cellResolution = list(cellResolutionParams.values())[0]
columns = cellResolution.split()[1]
value = 100*1/int(columns)
elif len(cellResolutionParams) > 1:
raise ValueError('Too many {} parameters in {}'.format(
unq_key,
cellResolutionParams
))
return '{:.6f}{}'.format(value, unit)
def _computeFontSize(
self, # Requires this to be used in the context of a StyleAttribute
specified: str,
parent: str,
params: dict[str, str]
) -> str:
# if it's a %, compute relative to parent, or cell height if no parent
basis = parent if parent else _getCellHeight(params)
if specified:
specified_matches = self.syntaxRegex.match(specified)
if specified_matches:
specified_percent_val = specified_matches.group('percent')
rv = _getPercentRelativeSize(specified_percent_val, basis) \
if specified \
else basis
else:
# if it's not a %, it's not conformant EBU-TT-D
raise ValueError('{} is not a valid fontSize'.format(specified))
else: # not specified, inherit from parent
rv = basis
# store in the params so lineHeight can be computed
params['fontSize'] = rv
return rv
def _fallbackComputeFontSize(
self, # Requires this to be used in the context of a StyleAttribute
specified: str,
parent: str,
params: dict[str, str]
) -> str:
# deal with potential non-conformant font size values:
# 1.3 <-- presumably treat as 130%
# 16px <-- if there's a tt@tts:extent in pixels, use that
# 1c <-- compute relative to the cell height
# 1c 2c <-- use vertical component only, then as 2c
# 8.0rh <-- use the number directly
# 5.0rw <-- really? we probably don't have an aspect ratio...
# 1.2em <-- treat as 120%
# TODO: this isn't right!
return self.defaultValue
def _parseColor(specified: str):
return specified
def _computeInheritedColor(
self, # Requires this to be used in the context of a StyleAttribute
specified: str,
parent: str,
params: dict[str, str]
) -> str:
# deal with potential conformant color values:
# #rrggbb (case insensitive hex)
# #rrggbbaa (case insensitive hex)
rv = self.defaultValue if not specified else _parseColor(specified)
if parent and not specified:
rv = parent
# try to parse the value
# TODO: this isn't right!
return rv
def _computeUninheritedColor(
self,
specified: str,
parent: str,
params: dict[str, str]
) -> str:
return _parseColor(specified) if specified else self.defaultValue
def _fallbackComputeColor(
self, # Requires this to be used in the context of a StyleAttribute
specified: str,
parent: str,
params: dict[str, str]
) -> str:
# deal with potential non-conformant color values:
# rgb(r, g, b)
# rgba(r, g, b, a)
# transparent (or other named color)
# TODO: this isn't right!
return self.defaultValue
def _computeLineHeight(
self,
specified: str,
parent: str,
params: dict[str, str]
) -> str:
rv = parent if parent else self.defaultValue
# if specified, compute relative to the computed font size
# (better have that in the params dict!)
if specified and specified != 'normal':
rv = _getPercentRelativeSize(specified, params['fontSize'])
elif specified and specified == 'normal':
rv = specified
# otherwise inherit the computed value from the parent
return rv
def _fallbackToDefault(
self,
specified: str,
parent: str,
params: dict[str, str]
) -> str:
return self.defaultValue
def _fallbackComputeUninheritedTwoLengthValue(
self,
specified: str,
parent: str,
params: dict[str, str]
) -> str:
print('_fallbackComputeUninheritedTwoLengthValue() for '
'{} with specified value {}\n'.format(self.tag, specified))
# TODO: compute values using other TTML-defined syntaxes, if possible.
return None
styleAttribs = \
[
StyleAttribute(
ns=styling_ns_suffix,
nsIsRelative=True,
tag='direction',
appliesTo=['p', 'span'],
syntaxRegex=re.compile(r'^(ltr)|(rtl)$'),
defaultValue='ltr',
computeValue=_computeSimpleInheritedAttribute,
fallbackComputeValue=_fallbackToDefault
),
StyleAttribute(
ns=styling_ns_suffix,
nsIsRelative=True,
tag='fontFamily',
appliesTo=['span'],
syntaxRegex=re.compile(
r"""^(([-]?([_a-zA-Z]|[^\0-\237\\])([_a-zA-Z0-9\-]|[^\0-\237\\])*)([\s]+([-]?([_a-zA-Z]|[^\0-\237\\])([_a-zA-Z0-9\-]|[^\0-\237\\])*))*|(\"([^\"\\]|\\.)*\")|('([^'\\]|\\.)*'))([\s]*,[\s]*(([-]?([_a-zA-Z]|[^\0-\237\\])([_a-zA-Z0-9\-]|[^\0-\237\\])*)([\s]+([-]?([_a-zA-Z]|[^\0-\237\\])([_a-zA-Z0-9\-]|[^\0-\237\\])*))*|(\"([^\"\\]|\\.)*\")|('([^'\\]|\\.)*')))*$"""),
defaultValue='default',
computeValue=_computeSimpleInheritedAttribute,
fallbackComputeValue=_fallbackToDefault
),
StyleAttribute(
ns=styling_ns_suffix,
nsIsRelative=True,
tag='fontSize',
appliesTo=['span'],
syntaxRegex=re.compile(r'^(?P<percent>[\d]+(\.[\d]+)?%)$'),
defaultValue='100%',
computeValue=_computeFontSize,
fallbackComputeValue=_fallbackComputeFontSize
),
StyleAttribute(
ns=styling_ns_suffix,
nsIsRelative=True,
tag='lineHeight',
appliesTo=['p'],
syntaxRegex=re.compile(r'^(normal)|([\d]+(\.[\d]+)?%)$'),
defaultValue='normal',
computeValue=_computeLineHeight,
fallbackComputeValue=_fallbackToDefault
),
StyleAttribute(
ns=styling_ns_suffix,
nsIsRelative=True,
tag='textAlign',
appliesTo=['p'],
syntaxRegex=re.compile(
r'^(left)|(center)|(right)|(start)|(end)|(justify)$'),
defaultValue='start',
computeValue=_computeSimpleInheritedAttribute,
fallbackComputeValue=_fallbackToDefault
),
StyleAttribute(
ns=styling_ns_suffix,
nsIsRelative=True,
tag='color',
appliesTo=['span'],
syntaxRegex=ebutt_distribution_color_type_regex,
defaultValue='#ffffffff',
computeValue=_computeInheritedColor,
fallbackComputeValue=_fallbackComputeColor
),
StyleAttribute(
ns=styling_ns_suffix,
nsIsRelative=True,
tag='backgroundColor',
appliesTo=['region', 'body', 'div', 'p', 'span'],
syntaxRegex=ebutt_distribution_color_type_regex,
defaultValue='#00000000',
computeValue=_computeUninheritedColor,
fallbackComputeValue=_fallbackComputeColor
),
StyleAttribute(
ns=styling_ns_suffix,
nsIsRelative=True,
tag='fontStyle',
appliesTo=['span'],
syntaxRegex=re.compile(r'^(normal)|(italic)$'),
defaultValue='normal',
computeValue=_computeSimpleInheritedAttribute,
fallbackComputeValue=_fallbackToDefault
),
StyleAttribute(
ns=styling_ns_suffix,
nsIsRelative=True,
tag='fontWeight',
appliesTo=['span'],
syntaxRegex=re.compile(r'^(normal)|(bold)$'),
defaultValue='normal',
computeValue=_computeSimpleInheritedAttribute,
fallbackComputeValue=_fallbackToDefault
),
StyleAttribute(
ns=styling_ns_suffix,
nsIsRelative=True,
tag='textDecoration',
appliesTo=['span'],
syntaxRegex=re.compile(r'^(none)|(underline)$'),
defaultValue='none',
computeValue=_computeSimpleInheritedAttribute,
fallbackComputeValue=_fallbackToDefault
),
StyleAttribute(
ns=styling_ns_suffix,
nsIsRelative=True,
tag='unicodeBidi',
appliesTo=['p', 'span'],
syntaxRegex=re.compile(r'^(normal)|(embed)|(bidiOverride)$'),
defaultValue='normal',
computeValue=_computeSimpleInheritedAttribute,
fallbackComputeValue=_fallbackToDefault
),
StyleAttribute(
ns=styling_ns_suffix,
nsIsRelative=True,
tag='wrapOption',
appliesTo=['span'],
syntaxRegex=re.compile(r'^(wrap)|(noWrap)$'),
defaultValue='wrap',
computeValue=_computeSimpleInheritedAttribute,
fallbackComputeValue=_fallbackToDefault
),
StyleAttribute(
ns=ebutts_ns,
nsIsRelative=False,
tag='multiRowAlign',
appliesTo=['p'],
syntaxRegex=re.compile(r'^(auto)|(start)|(center)|(end)$'),
defaultValue='auto',
computeValue=_computeSimpleInheritedAttribute,
fallbackComputeValue=_fallbackToDefault
),
StyleAttribute(
ns=ebutts_ns,
nsIsRelative=False,
tag='linePadding',
appliesTo=['p'],
syntaxRegex=re.compile(r'^([\d]+(\.[\d]+)?)c$'),
defaultValue='0c',
computeValue=_computeSimpleInheritedAttribute,
fallbackComputeValue=_fallbackToDefault # Not expecting weirdness
),
StyleAttribute(
ns=itts_ns,
nsIsRelative=False,
tag='fillLineGap',
appliesTo=['p'],
syntaxRegex=re.compile(r'^(false)|(true)$'),
defaultValue='false',
computeValue=_computeSimpleInheritedAttribute,
fallbackComputeValue=_fallbackToDefault
),
StyleAttribute(
ns=styling_ns_suffix,
nsIsRelative=True,
tag='origin',
appliesTo=['region'],
syntaxRegex=two_percent_vals_regex,
defaultValue='0% 0%',
computeValue=_computeUninheritedAttribute,
fallbackComputeValue=_fallbackComputeUninheritedTwoLengthValue
),
StyleAttribute(
ns=styling_ns_suffix,
nsIsRelative=True,
tag='extent',
appliesTo=['region'],
syntaxRegex=two_percent_vals_regex,
defaultValue='100% 100%',
computeValue=_computeUninheritedAttribute,
fallbackComputeValue=_fallbackComputeUninheritedTwoLengthValue
),
StyleAttribute(
ns=styling_ns_suffix,
nsIsRelative=True,
tag='displayAlign',
appliesTo=['region'], # Only on region in EBU-TT-D
syntaxRegex=re.compile(
r'^(before)|(center)|(after)$'),
defaultValue='before',
computeValue=_computeUninheritedAttribute,
fallbackComputeValue=_fallbackToDefault
),
StyleAttribute(
ns=styling_ns_suffix,
nsIsRelative=True,
tag='padding',
appliesTo=['region'], # Only on region in EBU-TT-D
syntaxRegex=re.compile(
r'^([\d]+(\.[\d]+)?%)([\s]+([\d]+(\.[\d]+)?%)){0,3}$'),
defaultValue='0%',
computeValue=_computeUninheritedAttribute,
fallbackComputeValue=_fallbackToDefault # TODO improve this
),
StyleAttribute(
ns=styling_ns_suffix,
nsIsRelative=True,
tag='writingMode',
appliesTo=['region'],
syntaxRegex=re.compile(
r'^(lrtb)|(rltb)|(tbrl)|(tblr)|(lr)|(rl)|(tb)$'),
defaultValue='lrtb',
computeValue=_computeUninheritedAttribute,
fallbackComputeValue=_fallbackToDefault
),
StyleAttribute(
ns=styling_ns_suffix,
nsIsRelative=True,
tag='showBackground',
appliesTo=['region'],
syntaxRegex=re.compile(
r'^(always)|(whenActive)$'),
defaultValue='always',
computeValue=_computeUninheritedAttribute,
fallbackComputeValue=_fallbackToDefault
),
StyleAttribute(
ns=styling_ns_suffix,
nsIsRelative=True,
tag='overflow',
appliesTo=['region'],
syntaxRegex=re.compile(
r'^(visible)|(hidden)$'),
defaultValue='hidden',
computeValue=_computeUninheritedAttribute,
fallbackComputeValue=_fallbackToDefault
),
StyleAttribute(
ns='',
nsIsRelative=False,
tag='style',
appliesTo=['style', 'region', 'body', 'div', 'p', 'span'],
# The following regex does not properly match IDREFS but is an
# approximation. It may falsely permit some non-conformant values
syntaxRegex=re.compile(
r'^([a-zA-Z_][\S]*([\t\f ]+([a-zA-Z_][\S]*))*)?$'),
defaultValue='',
computeValue=_computeUninheritedAttribute,
fallbackComputeValue=_fallbackToDefault
)
]
_elementsToApplicableStyleAttributes = {}
for styleAttrib in styleAttribs:
for el_tag in styleAttrib.appliesTo:
el_to_attrs = _elementsToApplicableStyleAttributes.get(
el_tag, set()
)
el_to_attrs.add(styleAttrib.tag)
_elementsToApplicableStyleAttributes[el_tag] = el_to_attrs
_allAttributeKeys = set(styleAttrib.tag for styleAttrib in styleAttribs)
def _makeTag(tt_ns: str, styleAttribute: StyleAttribute) -> str:
tag = styleAttribute.tag
ns = '{}{}'.format(
tt_ns if styleAttribute.nsIsRelative else '',
styleAttribute.ns)
return make_qname(ns, tag)
[docs]
def getStyleAttributeKeys(tt_ns: str, elements: list[str]) -> list[str]:
el_set = set(elements)
return [_makeTag(tt_ns=tt_ns, styleAttribute=sa)
for sa in styleAttribs
if not el_set.isdisjoint(sa.appliesTo)]
[docs]
def getStyleAttributeDict(
tt_ns: str, elements: list[str]) -> dict[str, StyleAttribute]:
el_set = set(elements)
return {_makeTag(tt_ns=tt_ns, styleAttribute=sa): sa
for sa in styleAttribs
if not el_set.isdisjoint(sa.appliesTo)}
[docs]
def getAllStyleAttributeKeys(tt_ns: str) -> list[str]:
return [_makeTag(tt_ns=tt_ns, styleAttribute=sa)
for sa in styleAttribs]
[docs]
def getAllStyleAttributeDict(tt_ns: str) -> dict[str, StyleAttribute]:
return {_makeTag(tt_ns=tt_ns, styleAttribute=sa): sa
for sa in styleAttribs}
[docs]
def attributeIsApplicableToElement(attr_key: str, el_tag: str) -> bool:
return \
attr_key not in _allAttributeKeys \
or attr_key in _elementsToApplicableStyleAttributes.get(el_tag, set())
[docs]
def canonicaliseFontFamily(fontFamily: str) -> list[str]:
if fontFamily is None:
fontFamily = 'default'
return [
ff.strip() for ff in fontFamily.split(',')
]
[docs]
def getMergedStyleSet(
el: Element,
id_to_styleattribs_map: dict[str, dict[str, str]],
) -> dict[str, str]:
style_attr_val = el.get('style', '')
ref_style_ids = style_attr_val.split()
style_set = {}
no_store_set = set([
'style',
xmlIdAttr,
])
# Merge referential and chained referential styles
for ref_style_id in ref_style_ids:
attrib_dict = id_to_styleattribs_map.get(ref_style_id, {})
for key, value in attrib_dict.items():
if key not in no_store_set:
style_set[key] = value
# Merge inline styles (even though there shouldn't be any)
tt_ns = get_namespace(el.tag)
style_attr_keys = getAllStyleAttributeKeys(tt_ns=tt_ns)
for key in style_attr_keys:
if key not in no_store_set and key in el.keys():
style_set[key] = el.get(key)
return style_set
[docs]
def computeStyles(
tt_ns: str,
validation_results: ValidationLogger,
el_sss: dict[str, str],
el_css: dict[str, str],
parent_css: dict[str, str],
params: dict[str, str],
error_significance: int = ERROR) -> bool:
valid = True
style_attrib_dict = getAllStyleAttributeDict(
tt_ns=tt_ns)
for style_key, style_attr in style_attrib_dict.items():
try:
specified = el_sss.get(style_key)
if specified and not style_attr.validateValue(specified):
raise ValueError('Value has invalid format')
el_css[style_attr.tag] = style_attr.computeValue(
specified=specified,
parent=parent_css.get(style_attr.tag),
params=params
)
except Exception as e:
valid = False
validation_results.append(ValidationResult(
status=error_significance,
location='{} styling attribute with value "{}"'.format(
style_key, el_sss.get(style_key)),
message=str(e),
code=ValidationCode.ttml_attribute_styling_attribute
))
fallback_css = style_attr.fallbackComputeValue(
specified=specified,
parent=parent_css.get(style_attr.tag),
params=params
)
if fallback_css:
el_css[style_attr.tag] = fallback_css
return valid
[docs]
def validateStyleAttr(
style_el: Element,
context: dict,
validation_results: ValidationLogger) -> bool:
valid = True
tt_ns = context.get('root_ns', 'http://www.w3.org/ns/ttml')
style_attr_dict = getAllStyleAttributeDict(tt_ns=tt_ns)
for a_key, a_val in style_el.items():
if a_key in style_attr_dict:
match = style_attr_dict[a_key].syntaxRegex.match(a_val)
if match is None:
valid = False
validation_results.error(
location='{}@{}'.format(
style_el.tag,
a_key),
message='Attribute value [{}] is invalid'.format(
a_val),
code=ValidationCode.ttml_attribute_styling_attribute
)
return valid