#!/usr/bin/env python # # Copyright (C) 2024 The Android Open Source Project # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # """Build XML.""" import dataclasses import functools from xml.dom import minidom from xml.etree import ElementTree from alias_builder import Alias from commandline import CommandlineArgs from fallback_builder import FallbackEntry from family_builder import Family from font_builder import Font @dataclasses.dataclass class XmlFont: """Class used for writing XML. All elements are str or None.""" file: str weight: str | None style: str | None index: str | None supported_axes: str | None post_script_name: str | None fallback_for: str | None axes: dict[str | str] def font_to_xml_font(font: Font, fallback_for=None) -> XmlFont: axes = None if font.axes: axes = {key: str(value) for key, value in font.axes.items()} return XmlFont( file=font.file, weight=str(font.weight) if font.weight is not None else None, style=font.style, index=str(font.index) if font.index is not None else None, supported_axes=font.supported_axes, post_script_name=font.post_script_name, fallback_for=fallback_for, axes=axes, ) @dataclasses.dataclass class XmlFamily: """Class used for writing XML. All elements are str or None.""" name: str | None lang: str | None variant: str | None fonts: [XmlFont] def family_to_xml_family(family: Family) -> XmlFamily: return XmlFamily( name=family.name, lang=family.lang, variant=family.variant, fonts=[font_to_xml_font(f) for f in family.fonts], ) @dataclasses.dataclass class XmlAlias: """Class used for writing XML. All elements are str or None.""" name: str to: str weight: str | None def alias_to_xml_alias(alias: Alias) -> XmlAlias: return XmlAlias( name=alias.name, to=alias.to, weight=str(alias.weight) if alias.weight is not None else None, ) @dataclasses.dataclass class FallbackXml: families: [XmlFamily] aliases: [XmlAlias] class FallbackOrder: """Provides a ordering of the family.""" def __init__(self, fallback: [FallbackEntry]): # Preprocess fallbacks from flatten key to priority value. # The priority is a index appeared the fallback entry. # The key will be lang or file prefixed string, e.g. "lang:und-Arab" -> 0, # "file:Roboto-Regular.ttf" -> 10, etc. fallback_priority = {} for priority, fallback in enumerate(fallback): if fallback.lang: fallback_priority['lang:%s' % fallback.lang] = priority else: # fallback.file is not None fallback_priority['id:%s' % fallback.id] = priority self.priority = fallback_priority def __call__(self, family: Family): """Returns priority of the family. Lower value means higher priority.""" priority = None if family.id: priority = self.priority.get('id:%s' % family.id) if not priority and family.lang: priority = self.priority.get('lang:%s' % family.lang) assert priority is not None, 'Unknown priority for %s' % family # Priority adjustments. # First, give extra score to compact for compatibility. priority = priority * 10 if family.variant == 'compact': priority = priority + 5 # Next, give extra priority score. The priority is -100 to 100, # Not to mixed in other scores, shift this range to 0 to 200 and give it # to current priority. priority = priority * 1000 custom_priority = family.priority if family.priority else 0 priority = priority + custom_priority + 100 return priority def generate_xml( fallback: [FallbackEntry], aliases: [Alias], families: [Family] ) -> FallbackXml: """Generats FallbackXML objects.""" # Step 1. Categorize families into following three. # The named family is converted to XmlFamily in this step. named_families: [str | XmlFamily] = {} # The list of Families used for locale fallback. fallback_families: [Family] = [] # The list of Families that has fallbackFor attribute. font_fallback_families: [Family] = [] for family in families: if family.name: # process named family assert family.name not in named_families, ( 'Duplicated named family entry: %s' % family.name ) named_families[family.name] = family_to_xml_family(family) elif family.fallback_for: font_fallback_families.append(family) else: fallback_families.append(family) # Step 2. Convert Alias to XmlAlias with validation. xml_aliases = [] available_names = set(named_families.keys()) for alias in aliases: assert alias.name not in available_names, ( 'duplicated name alias: %s' % alias ) available_names.add(alias.name) for alias in aliases: assert alias.to in available_names, 'unknown alias to: %s' % alias xml_aliases.append(alias_to_xml_alias(alias)) # Step 3. Reorder the fallback families with fallback priority. order = FallbackOrder(fallback) fallback_families.sort( key=functools.cmp_to_key(lambda l, r: order(l) - order(r)) ) for i, j in zip(fallback_families, fallback_families[1:]): assert order(i) != order(j), 'Same priority: %s vs %s' % (i, j) # Step 4. Place named families first. # Place sans-serif at the top of family list. assert 'sans-serif' in named_families, 'sans-serif family must exists' xml_families = [family_to_xml_family(named_families.pop('sans-serif'))] xml_families = xml_families + list(named_families.values()) # Step 5. Convert fallback_families from Family to XmlFamily. # Also create ID to XmlFamily map which is used for resolving fallbackFor # attributes. id_to_family: [str | XmlFamily] = {} for family in fallback_families: xml_family = family_to_xml_family(family) xml_families.append(xml_family) if family.id: id_to_family[family.id] = xml_family # Step 6. Add font fallback to the target XmlFamily for family in font_fallback_families: assert family.fallback_for in named_families, ( 'Unknown fallback for: %s' % family ) assert family.target in id_to_family, 'Unknown target for %s' % family xml_family = id_to_family[family.target] xml_family.fonts = xml_family.fonts + [ font_to_xml_font(f, family.fallback_for) for f in family.fonts ] # Step 7. Build output return FallbackXml(aliases=xml_aliases, families=xml_families) def write_xml(outfile: str, xml: FallbackXml): """Writes given xml object into into outfile as XML.""" familyset = ElementTree.Element('familyset') for family in xml.families: family_node = ElementTree.SubElement(familyset, 'family') if family.lang: family_node.set('lang', family.lang) if family.name: family_node.set('name', family.name) if family.variant: family_node.set('variant', family.variant) for font in family.fonts: font_node = ElementTree.SubElement(family_node, 'font') if font.weight: font_node.set('weight', font.weight) if font.style: font_node.set('style', font.style) if font.index: font_node.set('index', font.index) if font.supported_axes: font_node.set('supportedAxes', font.supported_axes) if font.fallback_for: font_node.set('fallbackFor', font.fallback_for) if font.post_script_name: font_node.set('postScriptName', font.post_script_name) font_node.text = font.file if font.axes: for tag, value in font.axes.items(): axis_node = ElementTree.SubElement(font_node, 'axis') axis_node.set('tag', tag) axis_node.set('stylevalue', value) for alias in xml.aliases: alias_node = ElementTree.SubElement(familyset, 'alias') alias_node.set('name', alias.name) alias_node.set('to', alias.to) if alias.weight: alias_node.set('weight', alias.weight) doc = minidom.parseString(ElementTree.tostring(familyset, 'utf-8')) with open(outfile, 'w') as f: doc.writexml(f, encoding='utf-8', newl='\n', indent='', addindent=' ') def main(args: CommandlineArgs): xml = generate_xml(args.fallback, args.aliases, args.families) write_xml(args.outfile, xml)