Source code for src.xmlChecks.regionRefsCheck

# SPDX-FileCopyrightText: Copyright © 2026 BBC
#
# SPDX-License-Identifier: BSD-3-Clause

from src.validationLogging.validationCodes import ValidationCode
from src.validationLogging.validationResult import ValidationResult, \
    ERROR, WARN
from src.validationLogging.validationLogger import ValidationLogger
from xml.etree.ElementTree import Element
from src.xmlUtils import make_qname, get_unqualified_name
from .xmlCheck import XmlCheck
from .ttmlUtils import ns_ttml
from src.styleAttribs import computeStyles, getMergedStyleSet, \
    ebutt_distribution_color_type_regex, two_percent_vals_regex
import logging


required_region_style_attrib_keys = [
    'origin',
    'extent',
    'displayAlign',
    'overflow',
]

bbc_required_region_style_attrib_keys = [
    'displayAlign',
    'overflow'
]

# Tech3380 is not very clear about which if
# any style attributes are actually ok on regions
ebuttd_optional_region_style_attrib_keys = [
    'backgroundColor',
    'color',
    'direction',
    'fontFamily',
    'fontSize',
    # TODO: base computed fontSize on the region fontSize
    'fontStyle',
    'fontWeight',
    'lineHeight',
    'padding',
    'showBackground',
    'textDecoration',
    'textAlign',
    'unicodeBidi',
    'writingMode',
]

ebuttd_required_region_style_attrib_keys = [
    'origin',
    'extent',
]


[docs] class regionRefsXmlCheck(XmlCheck): """ Checks for unreferenced regions and inappropriate style attributes on region elements. """ def _gather_region_refs( self, input: Element, parent_region_ref: str, valid_refs: dict[str, list[Element]], dropped_refs: dict[str, list[Element]], no_region_ps: list[Element], el_to_region_id_map: dict[Element, str], ) -> None: # Inner function for adding a reference to a list def add_ref( el: Element, region_ref: str, ref_map: dict[str, list[Element]] ) -> None: referencing_el_list = ref_map.get(region_ref, []) referencing_el_list.append(el) ref_map[region_ref] = referencing_el_list return # Get all region references by recursing down the tree # logging dropped references for elements that specify # a region that differs from the region specified by an # ancestor element. region_attr_key = 'region' region_ref = '' if region_attr_key in input.keys(): region_ref = input.get(region_attr_key) if parent_region_ref \ and region_ref \ and parent_region_ref != region_ref: add_ref(el=input, region_ref=region_ref, ref_map=dropped_refs) elif region_ref: add_ref(el=input, region_ref=region_ref, ref_map=valid_refs) el_to_region_id_map[input] = region_ref if get_unqualified_name(input.tag) == 'p' \ and not region_ref and not parent_region_ref: no_region_ps.append(input) if not region_ref: region_ref = parent_region_ref # Recurse through the tree for el in input: self._gather_region_refs( input=el, parent_region_ref=region_ref, valid_refs=valid_refs, dropped_refs=dropped_refs, no_region_ps=no_region_ps, el_to_region_id_map=el_to_region_id_map ) return
[docs] def checkSpecifiedStyles( self, tt_ns: str, sss: dict[str, str], validation_results: ValidationLogger, location: str, error_significance: int = ERROR, ) -> bool: valid = True ns_qualified_required_region_style_attribs = \ [make_qname(tt_ns + '#styling', k) for k in required_region_style_attrib_keys] ns_qualified_optional_region_style_attribs = \ [make_qname(tt_ns + '#styling', k) for k in ebuttd_optional_region_style_attrib_keys] for attr_key in ns_qualified_required_region_style_attribs: if attr_key not in sss: if error_significance == ERROR: valid = False code = ValidationCode.ebuttd_region_attributes_constraint \ if get_unqualified_name(attr_key) in \ ebuttd_required_region_style_attrib_keys \ else ValidationCode.bbc_region_attributes_constraint \ if get_unqualified_name(attr_key) in \ bbc_required_region_style_attrib_keys \ else None validation_results.append(ValidationResult( status=error_significance, location=location, message='Required style attribute {} ' 'missing from region element' .format(attr_key), code=code )) permitted_ns_qualified_region_style_attribs = \ ns_qualified_optional_region_style_attribs + \ ns_qualified_required_region_style_attribs # print(sss) for sss_key in sss: if sss_key not in permitted_ns_qualified_region_style_attribs: validation_results.warn( location=location, message='Non-permitted style attribute {} ' 'present on region element - ' 'presentation may differ from expectation' .format(sss_key), code=ValidationCode.ttml_attribute_styling_attribute ) return valid
def _getRegionBbcEdgeLimits( self, context: dict ) -> tuple[float, float, float, float]: # leftMin, rightMax, topMin, bottomMax # TODO: allow for square, 4:3, explicit 16:9 as well vertical = context.get('args', {}).get('vertical', False) if not vertical: # For 16:9 this would be 12.5 <--> 87.5 horizontally, # but the guidelines allow for 9.5 <--> 91.5 for square and 4:3 return (9.5, 91.5, 5, 95) else: return (5, 95, 10, 90)
[docs] def checkComputedStyles( self, css: dict[str, str], validation_results: ValidationLogger, location: str, context: dict, error_significance: int = ERROR, ) -> bool: valid = True error_validity = False if error_significance == ERROR else True # Iterate through the computed styles that # apply to region checking value is ok # backgroundColor c_bgc = css.get('backgroundColor', '[invalid value]') c_bgc_match = ebutt_distribution_color_type_regex.match(c_bgc) c_bgc_alpha = c_bgc_match.group('a') if c_bgc_match else None if not c_bgc_alpha or c_bgc_alpha != '00': valid = error_validity validation_results.append(ValidationResult( status=error_significance, location=location, message='backgroundColor value {} is non-transparent ' 'and does not meet BBC requirements' .format(c_bgc), code=ValidationCode.bbc_region_backgroundColor_constraint )) # origin and extent must not make a region that goes outside the # rendering area. The regex prevents negative values. c_origin = css.get('origin', '[invalid value]') c_origin_match = two_percent_vals_regex.match(c_origin) c_extent = css.get('extent', '[invalid value]') c_extent_match = two_percent_vals_regex.match(c_extent) if not c_origin_match or not c_extent_match: valid = error_validity validation_results.append(ValidationResult( status=error_significance, location=location, message='Not got computed values for both origin and extent', code=ValidationCode.ttml_attribute_styling_attribute )) else: left_edge = float(c_origin_match.group('x')) right_edge = \ left_edge \ + float(c_extent_match.group('x')) top_edge = float(c_origin_match.group('y')) bottom_edge = \ top_edge \ + float(c_extent_match.group('y')) if round(right_edge, 3) > 100.0: valid = error_validity validation_results.append(ValidationResult( status=error_significance, location=location, message='Region right edge {}% ' 'goes beyond 100%' .format(right_edge), code=ValidationCode.ebuttd_region_position_constraint )) if round(bottom_edge, 3) > 100.0: valid = error_validity validation_results.append(ValidationResult( status=error_significance, location=location, message='Region bottom edge {}% ' 'goes beyond 100%' .format(bottom_edge), code=ValidationCode.ebuttd_region_position_constraint )) # Also check for BBC-defined limits leftMin, rightMax, topMin, bottomMax = \ self._getRegionBbcEdgeLimits(context=context) if round(left_edge) < leftMin \ or round(right_edge) > rightMax \ or round(top_edge) < topMin \ or round(bottom_edge) > bottomMax: valid = error_validity validation_results.append(ValidationResult( status=error_significance, location=location, message='Region extends out of BBC-defined ' 'permitted area ({}%-{}% horizontally ' 'and {}%-{}% vertically)'.format( leftMin, rightMax, topMin, bottomMax), code=ValidationCode.bbc_region_position_constraint )) # displayAlign - BBC requirement to be in specified set, # so if we've got this far then it must be valid already # padding - BBC says nothing about this, EBU-TT-D permits it; # any valid value is ok # writingMode - any valid value is ok # showBackground - any valid value is ok # (but the backgroundColor has to be transparent anyway!) # overflow - BBC requirement to set to visible c_overflow = css.get('overflow', '[invalid value]') if c_overflow != 'visible': valid = error_validity validation_results.append(ValidationResult( status=error_significance, location=location, message='Region overflow {} ' 'not visible (BBC requirement)' .format(c_overflow), code=ValidationCode.bbc_region_overflow_constraint )) return valid
[docs] def run( self, input: Element, context: dict, validation_results: ValidationLogger) -> bool: valid = True skip = False # Gather region references from body, div, p and span # elements in a map from region xml:id to referencing element 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.skip( location='{}/{}'.format(input.tag, body_el_tag), message='Found {} body elements, expected 1; ' 'skipping region reference checks' .format( len(bodies)), code=ValidationCode.ttml_layout_region_association ) skip = True else: body_el = bodies[0] valid_refs = {} dropped_refs = {} no_region_ps = [] el_to_region_id_map = {} self._gather_region_refs( input=body_el, parent_region_ref='', valid_refs=valid_refs, dropped_refs=dropped_refs, no_region_ps=no_region_ps, el_to_region_id_map=el_to_region_id_map) context['elements_to_region_id_map'] = el_to_region_id_map # Report WARN for all region elements that are not referenced if 'id_to_region_map' not in context: logging.warning( 'regionRefsCheck not checking for unreferenced' 'region elements - no context[id_to_region_map]') validation_results.skip( location='document', message='regionRefsCheck not checking for unreferenced' 'region elements - no context[id_to_region_map]', code=ValidationCode.ttml_element_region ) skip = True else: region_id_to_css_map = \ context.get('region_id_to_css_map', {}) for region_id in context['id_to_region_map'].keys(): style_error_significance = ERROR if region_id not in dropped_refs \ and region_id not in valid_refs: validation_results.warn( location='region element xml:id {}' .format(region_id), message='Unreferenced region element', code=ValidationCode.ttml_element_region ) if region_id in dropped_refs: validation_results.warn( location='region element xml:id {}' .format(region_id), message='{} elements pruned because their ' 'ancestor references a different ' 'region element' .format(len(dropped_refs[region_id])), code=ValidationCode.ttml_layout_region_association ) if region_id not in valid_refs: style_error_significance = WARN # Validate the style attributes on this region # and WARN on problems if it is unreferenced, # otherwise ERROR region_el = context['id_to_region_map'][region_id] if 'id_to_style_attribs_map' not in context: logging.warning( 'regionRefsCheck not checking region style' 'attributes - no context[id_to_style_attribs_map]') validation_results.skip( location='region element xml:id {}' .format(region_id), message='regionRefsCheck not checking region style' 'attributes - no ' 'context[id_to_style_attribs_map]', code=ValidationCode .ttml_styling_attribute_applicability ) skip = True else: id_to_styleattribs_map = \ context['id_to_style_attribs_map'] region_sss = getMergedStyleSet( region_el, id_to_styleattribs_map=id_to_styleattribs_map) location = 'region element xml:id {}'.format(region_id) valid &= self.checkSpecifiedStyles( tt_ns=tt_ns, sss=region_sss, validation_results=validation_results, location=location, error_significance=style_error_significance ) region_css = {} params = {} valid &= computeStyles( tt_ns=tt_ns, validation_results=validation_results, el_sss=region_sss, el_css=region_css, parent_css={}, params=params, error_significance=style_error_significance ) # Also check the region specific computed styles are in # range valid &= self.checkComputedStyles( css=region_css, validation_results=validation_results, location=location, context=context, error_significance=style_error_significance) region_id_to_css_map[region_id] = region_css # Store this for overlapping region computation later context['region_id_to_css_map'] = region_id_to_css_map # Check for region references that # don't point to region elements for region_id in valid_refs.keys(): if region_id not in context['id_to_region_map']: valid = False validation_results.error( location='{} element(s)' .format(len(valid_refs[region_id])), message='Referenced region {} does not point ' 'to a region element' .format(region_id), code=ValidationCode.ttml_layout_region_association ) for region_id in dropped_refs.keys(): if region_id not in context['id_to_region_map']: valid = False validation_results.error( location='{} element(s)' .format(len(dropped_refs[region_id])), message='Dropped referenced region {} does not ' 'point to a region element' .format(region_id), code=ValidationCode.ttml_layout_region_association ) # Report ERROR for any p elements not associated with a region if len(no_region_ps) > 0: valid = False validation_results.error( location='{} p element(s)'.format(len(no_region_ps)), message='Elements not associated with a region', code=ValidationCode.ttml_layout_region_association ) if valid and not skip: validation_results.good( location='document', message='Region references and attributes checked', code=ValidationCode.ttml_layout_region_association ) return valid