# SPDX-FileCopyrightText: Copyright © 2026 BBC
#
# SPDX-License-Identifier: BSD-3-Clause
from src.validationLogging.validationCodes import ValidationCode
from src.validationLogging.validationLogger import ValidationLogger
from xml.etree.ElementTree import Element
from src.xmlUtils import make_qname, xmlIdAttr, get_unqualified_name
from .xmlCheck import XmlCheck
from .ttmlUtils import ns_ttml
from src.styleAttribs import getAllStyleAttributeDict, \
attributeIsApplicableToElement, \
canonicaliseFontFamily, computeStyles, getMergedStyleSet
import logging
permitted_color_values = [
'#ffffff', # white
'#ffffffff',
'#00ffff', # cyan
'#00ffffff',
'#ffff00', # yellow
'#ffff00ff',
'#00ff00', # green
'#00ff00ff',
]
permitted_span_backgroundColor_values = [
'#000000',
'#000000ff'
]
[docs]
class styleRefsXmlCheck(XmlCheck):
"""
Checks for unreferenced styles and inappropriate style attributes.
"""
def _gather_style_refs(
self,
input: Element,
) -> dict[str, list[Element]]:
style_to_referencing_el_map = {}
style_attr_key = 'style'
for el in input.iter():
if style_attr_key in el.keys():
style_refs = el.get(style_attr_key).split()
for style_ref in style_refs:
referencing_el_list = \
style_to_referencing_el_map.get(style_ref, [])
referencing_el_list.append(el)
style_to_referencing_el_map[style_ref] =\
referencing_el_list
return style_to_referencing_el_map
def _get_style_attrib_map(
self,
style_el: Element,
id_to_style_map: dict[str, Element],
style_attrib_map: dict[str, str],
visited_styles: list[str],
validation_results: ValidationLogger,
) -> bool:
valid = True
style_refs = style_el.get('style', '').split()
for style_ref in style_refs:
if style_ref not in visited_styles:
visited_styles.append(style_ref)
valid &= self._get_style_attrib_map(
id_to_style_map[style_ref],
id_to_style_map=id_to_style_map,
style_attrib_map=style_attrib_map,
visited_styles=visited_styles,
validation_results=validation_results
)
else:
validation_results.error(
location='style element',
message='Cyclic style ref to {} found'.format(
style_ref),
code=ValidationCode.ttml_styling_referential_chained
)
valid = False
for key, value in style_el.items():
if key != 'style':
style_attrib_map[key] = value
return valid
def _gather_style_attribs(
self,
id_to_style_map: dict[str, Element],
validation_results: ValidationLogger,
id_to_styleattribs_map: dict[str, dict[str, str]],
) -> bool:
valid = True
for id, style_el in id_to_style_map.items():
attrib_map = {}
visited = []
valid &= self._get_style_attrib_map(
style_el=style_el,
id_to_style_map=id_to_style_map,
style_attrib_map=attrib_map,
visited_styles=visited,
validation_results=validation_results
)
id_to_styleattribs_map[id] = attrib_map
return valid
def _check_attr_applicability(
self,
tag: str,
sss: dict,
validation_results: ValidationLogger
) -> bool:
valid = True
for attr_key in sss.keys():
if not attributeIsApplicableToElement(
attr_key=get_unqualified_name(
attr_key), el_tag=tag):
valid = False
validation_results.error(
location='{} element'.format(tag),
message='Specified style attribute {} is not '
'applicable to element type {}'
.format(attr_key, tag),
code=ValidationCode.ttml_styling_attribute_applicability
)
return valid
def _check_no_backgroundColor(
self,
sss: dict[str, str],
el_tag: str,
tt_ns: str,
validation_results: ValidationLogger
) -> bool:
valid = True
style_attrib_dict = getAllStyleAttributeDict(
tt_ns=tt_ns)
for style_attr_key, style_attr in style_attrib_dict.items():
unq_attr_key = get_unqualified_name(style_attr_key)
if unq_attr_key == 'backgroundColor' \
and style_attr_key in sss:
backgroundColor_val = sss[style_attr_key]
parsed_bg = style_attr.syntaxRegex.fullmatch(
backgroundColor_val)
if parsed_bg is None:
valid = False
validation_results.error(
location='{} element {} attribute'
.format(el_tag, style_attr_key),
message='backgroundColor attribute {} '
'is not valid'
.format(backgroundColor_val),
code=ValidationCode.ttml_attribute_styling_attribute
)
else:
a = int(parsed_bg.group('a'), 16) \
if parsed_bg.group('a') else 255
if a != 0:
valid = False
validation_results.error(
location='{} element {} attribute'
.format(
el_tag,
style_attr_key),
message='backgroundColor {} is not '
'transparent (BBC requirement)'
.format(backgroundColor_val),
code=ValidationCode
.bbc_block_backgroundColor_constraint
)
return valid
def _getFontSizeMinMax(self, context: dict) -> tuple[float, float]:
min_fs = 6
max_fs = 7.5
if context.get('args', {}).get('vertical', False):
min_fs = 3
max_fs = 4.5
return (min_fs, max_fs)
def _check_styles(
self,
el: Element,
context: dict,
validation_results: ValidationLogger,
tt_ns: str,
parent_css: dict) -> bool:
valid = True
el_tag = get_unqualified_name(el.tag)
validation_location = \
'{} element xml:id {}'.format(
el_tag, el.get(xmlIdAttr, 'omitted'))
id_to_styleattribs_map = context['id_to_style_attribs_map']
# Iterate through the elements from body down to span
# For each, gather the specified style set
# and compute the computed styles, then pass the
# computed style set down to each child to compute
# its style set.
el_sss = getMergedStyleSet(
el=el,
id_to_styleattribs_map=id_to_styleattribs_map
)
# For all references from span elements, check that the referenced
# attributes apply to span, and ERROR for any that do not.
if el_tag == 'span':
valid &= self._check_attr_applicability(
tag=el_tag,
sss=el_sss,
validation_results=validation_results)
# For all references from elements other than span, check that
# there is no non-transparent tts:backgroundColor attribute
# (BBC requirement) - if there is, ERROR
if el_tag in ['region', 'body', 'div', 'p']:
valid &= self._check_no_backgroundColor(
sss=el_sss,
el_tag=el_tag,
tt_ns=tt_ns,
validation_results=validation_results)
# Generate the computed style set
el_css = {}
params = {}
cell_resolution_key = 'cellResolution'
if cell_resolution_key in context:
params[cell_resolution_key] = context[cell_resolution_key]
valid &= computeStyles(
tt_ns=tt_ns,
validation_results=validation_results,
el_sss=el_sss,
el_css=el_css,
parent_css=parent_css,
params=params
)
min_fs, max_fs = self._getFontSizeMinMax(context=context)
min_lh = 1.2 * min_fs
max_lh = 1.2 * max_fs
if el_tag in ['p', 'span']:
# Check for referenced style lists that have wrong computed
# tts:fontFamily value (BBC requirement) - if there is, ERROR
c_font_family = canonicaliseFontFamily(
el_css.get('fontFamily', 'default'))
required_font_family = canonicaliseFontFamily(
'ReithSans, Arial, Roboto, proportionalSansSerif, default')
if c_font_family != required_font_family:
valid = False
validation_results.error(
location=validation_location,
message='Computed fontFamily {} differs'
' from BBC requirement'
.format(c_font_family),
code=ValidationCode.bbc_text_fontFamily_constraint
)
# Compute fontSize for every p and span,
# and check it is within BBC range 2% - 8%
# ERROR if not
c_font_size = el_css.get('fontSize', '')
if c_font_size[-2:] != 'rh':
raise RuntimeError(
'Non-canonical computed fontSize {}'.format(c_font_size))
c_font_size_val = float(c_font_size[:-2])
if c_font_size_val < min_fs or c_font_size_val > max_fs:
valid = False
validation_results.error(
location=validation_location,
message='Computed fontSize {:.3f}rh outside '
'BBC-allowed range {}rh-{}rh'
.format(c_font_size_val, min_fs, max_fs),
code=ValidationCode.bbc_text_fontSize_constraint
)
else:
validation_results.good(
location=validation_location,
message='Computed fontSize {:.3f}rh '
'(within BBC-allowed range)'
.format(c_font_size_val),
code=ValidationCode.bbc_text_fontSize_constraint
)
# Compute lineHeight for every p, ERROR if <100% or >130%,
# WARN if "normal"
if el_tag == 'p':
c_line_height = el_css.get('lineHeight', 'broken')
# print('lineHeight = {}'.format(c_line_height))
if c_line_height == 'normal':
validation_results.warn(
location=validation_location,
message='lineHeight normal used - '
'SHOULD use explicit percentage',
code=ValidationCode.bbc_text_lineHeight_constraint
)
else:
if c_line_height[-2:] != 'rh':
raise RuntimeError(
'Non-canonical computed lineHeight {}'.format(
c_line_height))
c_line_height_val = float(c_line_height[:-2])
if c_line_height_val < min_lh \
or c_line_height_val > max_lh:
valid = False
validation_results.error(
location=validation_location,
message='Computed lineHeight {:.3f}rh outside '
'BBC-allowed range {:.3f}rh-{:.3f}rh'
.format(c_line_height_val, min_lh, max_lh),
code=ValidationCode.bbc_text_lineHeight_constraint
)
# For every p, check if ebutts:multiRowAlign is present (INFO) and
# if not auto and different from tts:textAlign,
# WARN (BBC requirement)
c_ta = el_css.get('textAlign')
c_mra = el_css.get('multiRowAlign')
# print('multiRowAlign = {}, textAlign = {}'.format(c_mra, c_ta))
if c_mra != 'auto' and c_mra == c_ta:
validation_results.info(
location=validation_location,
message='Computed multiRowAlign set to {}, '
'matches textAlign'
.format(c_mra),
code=ValidationCode.ebuttd_multiRowAlign
)
elif c_mra != 'auto':
validation_results.warn(
location=validation_location,
message='Computed multiRowAlign set to {}, '
'differs from textAlign {} '
'(Not expected in BBC requirements)'
.format(c_mra, c_ta),
code=ValidationCode.bbc_text_multiRowAlign_constraint
)
# For every p, check ebutts:linePadding - ERROR if absent,
# ERROR if out of range
c_lp = el_css.get('linePadding')
# print('linePadding = {}'.format(c_lp))
if c_lp[-1:] != 'c':
raise RuntimeError(
'Non-canonical computed linePadding {}'.format(c_lp))
c_lp_val = float(c_lp[:-1])
if c_lp_val < 0.3 or c_lp_val > 0.8:
valid = False
validation_results.error(
location=validation_location,
message='Computed linePadding {} outside BBC-allowed range'
.format(c_lp),
code=ValidationCode.bbc_text_linePadding_constraint
)
else:
validation_results.good(
location=validation_location,
message='Computed linePadding {} within BBC-allowed range'
.format(c_lp),
code=ValidationCode.bbc_text_linePadding_constraint
)
# For every p, check itts:fillLineGap - ERROR if not true
c_flg = el_css.get('fillLineGap')
# print('fillLineGap = {}'.format(c_flg))
if c_flg != 'true':
valid = False
validation_results.error(
location=validation_location,
message='Computed fillLineGap {} not BBC-allowed value'
.format(c_flg),
code=ValidationCode.bbc_text_fillLineGap_constraint
)
if el_tag == 'span':
# For every span, check tts:color - ERROR if not a permitted color
c_c = el_css.get('color').lower()
# print('color = {}'.format(c_c))
if c_c not in permitted_color_values:
valid = False
validation_results.error(
location=validation_location,
message='Computed color {} not BBC-allowed value'
.format(c_c),
code=ValidationCode.bbc_text_color_constraint
)
# For every span, check tts:backgroundColor - ERROR if not a
# permitted color (black)
c_bc = el_css.get('backgroundColor').lower()
# print('backgroundColor = {}'.format(c_bc))
if c_bc not in permitted_span_backgroundColor_values:
valid = False
validation_results.error(
location=validation_location,
message='Computed backgroundColor {} not BBC-allowed value'
.format(c_bc),
code=ValidationCode.bbc_text_backgroundColor_constraint
)
# For every span, check tts:fontStyle - WARN if "italic"
c_fs = el_css.get('fontStyle')
# print('fontStyle = {}'.format(c_fs))
if c_fs != 'normal':
validation_results.warn(
location=validation_location,
message='Computed fontStyle {} not in general use for BBC'
.format(c_fs),
code=ValidationCode.bbc_text_fontStyle_constraint
)
# Recursively call for each child element, passing in el_sss and el_css
for child_el in el:
valid &= self._check_styles(
el=child_el,
context=context,
validation_results=validation_results,
tt_ns=tt_ns,
parent_css=el_css
)
return valid
[docs]
def run(
self,
input: Element,
context: dict,
validation_results: ValidationLogger) -> bool:
valid = True
skip = False
# Gather style references from style, region, body, div, p and span
# elements in a map from style xml:id to referencing element
style_to_referencing_els_map = self._gather_style_refs(input=input)
# Report WARN for all style elements that are not referenced
if 'id_to_style_map' not in context:
logging.warning(
'styleRefsCheck not checking for unreferenced'
'style elements - no context[id_to_style_map]')
validation_results.skip(
location='document',
message='styleRefsCheck not checking for unreferenced'
'style elements - no context[id_to_style_map]',
code=ValidationCode.ttml_styling
)
skip = True
else:
for style_id in context['id_to_style_map'].keys():
if style_id not in style_to_referencing_els_map:
validation_results.warn(
location='style xml:id={}'.format(style_id),
message='Unreferenced style element',
code=ValidationCode.ttml_element_style
)
for style_id in style_to_referencing_els_map.keys():
if style_id not in context['id_to_style_map']:
validation_results.warn(
location='style xml:id={}'.format(style_id),
message='Referenced id does not point '
'to a style element',
code=ValidationCode.ttml_styling_reference
)
# Compute list of style attributes and values for each
# referenced style
id_to_styleattribs_map = {}
valid &= self._gather_style_attribs(
id_to_style_map=context['id_to_style_map'],
validation_results=validation_results,
id_to_styleattribs_map=id_to_styleattribs_map
)
context['id_to_style_attribs_map'] = id_to_styleattribs_map
tt_ns = \
context.get('root_ns', ns_ttml)
body_el_tag = make_qname(tt_ns, 'body')
bodies = [el for el in input if el.tag == body_el_tag]
if len(bodies) != 1:
validation_results.error(
location='{}/{}'.format(input.tag, body_el_tag),
message='Found {} body elements, expected 1'.format(
len(bodies)),
code=ValidationCode.ttml_element_body
)
valid = False
else:
body_el = bodies[0]
valid &= self._check_styles(
el=body_el,
context=context,
validation_results=validation_results,
tt_ns=tt_ns,
parent_css={})
if valid and not skip:
validation_results.good(
location='document',
message='Style references and attributes checked',
code=ValidationCode.ttml_styling
)
return valid