1#!/usr/bin/env python 2 3# 4# Copyright (C) 2024 The Android Open Source Project 5# 6# Licensed under the Apache License, Version 2.0 (the "License"); 7# you may not use this file except in compliance with the License. 8# You may obtain a copy of the License at 9# 10# http://www.apache.org/licenses/LICENSE-2.0 11# 12# Unless required by applicable law or agreed to in writing, software 13# distributed under the License is distributed on an "AS IS" BASIS, 14# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15# See the License for the specific language governing permissions and 16# limitations under the License. 17# 18 19"""Build XML.""" 20 21import dataclasses 22import functools 23from xml.dom import minidom 24from xml.etree import ElementTree 25from alias_builder import Alias 26from commandline import CommandlineArgs 27from fallback_builder import FallbackEntry 28from family_builder import Family 29from font_builder import Font 30 31 32@dataclasses.dataclass 33class XmlFont: 34 """Class used for writing XML. All elements are str or None.""" 35 36 file: str 37 weight: str | None 38 style: str | None 39 index: str | None 40 supported_axes: str | None 41 post_script_name: str | None 42 fallback_for: str | None 43 axes: dict[str | str] 44 45 46def font_to_xml_font(font: Font, fallback_for=None) -> XmlFont: 47 axes = None 48 if font.axes: 49 axes = {key: str(value) for key, value in font.axes.items()} 50 return XmlFont( 51 file=font.file, 52 weight=str(font.weight) if font.weight is not None else None, 53 style=font.style, 54 index=str(font.index) if font.index is not None else None, 55 supported_axes=font.supported_axes, 56 post_script_name=font.post_script_name, 57 fallback_for=fallback_for, 58 axes=axes, 59 ) 60 61 62@dataclasses.dataclass 63class XmlFamily: 64 """Class used for writing XML. All elements are str or None.""" 65 66 name: str | None 67 lang: str | None 68 variant: str | None 69 fonts: [XmlFont] 70 71 72def family_to_xml_family(family: Family) -> XmlFamily: 73 return XmlFamily( 74 name=family.name, 75 lang=family.lang, 76 variant=family.variant, 77 fonts=[font_to_xml_font(f) for f in family.fonts], 78 ) 79 80 81@dataclasses.dataclass 82class XmlAlias: 83 """Class used for writing XML. All elements are str or None.""" 84 85 name: str 86 to: str 87 weight: str | None 88 89 90def alias_to_xml_alias(alias: Alias) -> XmlAlias: 91 return XmlAlias( 92 name=alias.name, 93 to=alias.to, 94 weight=str(alias.weight) if alias.weight is not None else None, 95 ) 96 97 98@dataclasses.dataclass 99class FallbackXml: 100 families: [XmlFamily] 101 aliases: [XmlAlias] 102 103 104class FallbackOrder: 105 """Provides a ordering of the family.""" 106 107 def __init__(self, fallback: [FallbackEntry]): 108 # Preprocess fallbacks from flatten key to priority value. 109 # The priority is a index appeared the fallback entry. 110 # The key will be lang or file prefixed string, e.g. "lang:und-Arab" -> 0, 111 # "file:Roboto-Regular.ttf" -> 10, etc. 112 fallback_priority = {} 113 for priority, fallback in enumerate(fallback): 114 if fallback.lang: 115 fallback_priority['lang:%s' % fallback.lang] = priority 116 else: # fallback.file is not None 117 fallback_priority['id:%s' % fallback.id] = priority 118 119 self.priority = fallback_priority 120 121 def __call__(self, family: Family): 122 """Returns priority of the family. Lower value means higher priority.""" 123 priority = None 124 if family.id: 125 priority = self.priority.get('id:%s' % family.id) 126 if not priority and family.lang: 127 priority = self.priority.get('lang:%s' % family.lang) 128 129 assert priority is not None, 'Unknown priority for %s' % family 130 131 # Priority adjustments. 132 # First, give extra score to compact for compatibility. 133 priority = priority * 10 134 if family.variant == 'compact': 135 priority = priority + 5 136 137 # Next, give extra priority score. The priority is -100 to 100, 138 # Not to mixed in other scores, shift this range to 0 to 200 and give it 139 # to current priority. 140 priority = priority * 1000 141 custom_priority = family.priority if family.priority else 0 142 priority = priority + custom_priority + 100 143 144 return priority 145 146 147def generate_xml( 148 fallback: [FallbackEntry], aliases: [Alias], families: [Family] 149) -> FallbackXml: 150 """Generats FallbackXML objects.""" 151 152 # Step 1. Categorize families into following three. 153 154 # The named family is converted to XmlFamily in this step. 155 named_families: [str | XmlFamily] = {} 156 # The list of Families used for locale fallback. 157 fallback_families: [Family] = [] 158 # The list of Families that has fallbackFor attribute. 159 font_fallback_families: [Family] = [] 160 161 for family in families: 162 if family.name: # process named family 163 assert family.name not in named_families, ( 164 'Duplicated named family entry: %s' % family.name 165 ) 166 named_families[family.name] = family_to_xml_family(family) 167 elif family.fallback_for: 168 font_fallback_families.append(family) 169 else: 170 fallback_families.append(family) 171 172 # Step 2. Convert Alias to XmlAlias with validation. 173 xml_aliases = [] 174 available_names = set(named_families.keys()) 175 for alias in aliases: 176 assert alias.name not in available_names, ( 177 'duplicated name alias: %s' % alias 178 ) 179 available_names.add(alias.name) 180 181 for alias in aliases: 182 assert alias.to in available_names, 'unknown alias to: %s' % alias 183 xml_aliases.append(alias_to_xml_alias(alias)) 184 185 # Step 3. Reorder the fallback families with fallback priority. 186 order = FallbackOrder(fallback) 187 fallback_families.sort( 188 key=functools.cmp_to_key(lambda l, r: order(l) - order(r)) 189 ) 190 for i, j in zip(fallback_families, fallback_families[1:]): 191 assert order(i) != order(j), 'Same priority: %s vs %s' % (i, j) 192 193 # Step 4. Place named families first. 194 # Place sans-serif at the top of family list. 195 assert 'sans-serif' in named_families, 'sans-serif family must exists' 196 xml_families = [family_to_xml_family(named_families.pop('sans-serif'))] 197 xml_families = xml_families + list(named_families.values()) 198 199 # Step 5. Convert fallback_families from Family to XmlFamily. 200 # Also create ID to XmlFamily map which is used for resolving fallbackFor 201 # attributes. 202 id_to_family: [str | XmlFamily] = {} 203 for family in fallback_families: 204 xml_family = family_to_xml_family(family) 205 xml_families.append(xml_family) 206 if family.id: 207 id_to_family[family.id] = xml_family 208 209 # Step 6. Add font fallback to the target XmlFamily 210 for family in font_fallback_families: 211 assert family.fallback_for in named_families, ( 212 'Unknown fallback for: %s' % family 213 ) 214 assert family.target in id_to_family, 'Unknown target for %s' % family 215 216 xml_family = id_to_family[family.target] 217 xml_family.fonts = xml_family.fonts + [ 218 font_to_xml_font(f, family.fallback_for) for f in family.fonts 219 ] 220 221 # Step 7. Build output 222 return FallbackXml(aliases=xml_aliases, families=xml_families) 223 224 225def write_xml(outfile: str, xml: FallbackXml): 226 """Writes given xml object into into outfile as XML.""" 227 familyset = ElementTree.Element('familyset') 228 229 for family in xml.families: 230 family_node = ElementTree.SubElement(familyset, 'family') 231 if family.lang: 232 family_node.set('lang', family.lang) 233 if family.name: 234 family_node.set('name', family.name) 235 if family.variant: 236 family_node.set('variant', family.variant) 237 238 for font in family.fonts: 239 font_node = ElementTree.SubElement(family_node, 'font') 240 if font.weight: 241 font_node.set('weight', font.weight) 242 if font.style: 243 font_node.set('style', font.style) 244 if font.index: 245 font_node.set('index', font.index) 246 if font.supported_axes: 247 font_node.set('supportedAxes', font.supported_axes) 248 if font.fallback_for: 249 font_node.set('fallbackFor', font.fallback_for) 250 if font.post_script_name: 251 font_node.set('postScriptName', font.post_script_name) 252 253 font_node.text = font.file 254 255 if font.axes: 256 for tag, value in font.axes.items(): 257 axis_node = ElementTree.SubElement(font_node, 'axis') 258 axis_node.set('tag', tag) 259 axis_node.set('stylevalue', value) 260 261 for alias in xml.aliases: 262 alias_node = ElementTree.SubElement(familyset, 'alias') 263 alias_node.set('name', alias.name) 264 alias_node.set('to', alias.to) 265 if alias.weight: 266 alias_node.set('weight', alias.weight) 267 268 doc = minidom.parseString(ElementTree.tostring(familyset, 'utf-8')) 269 with open(outfile, 'w') as f: 270 doc.writexml(f, encoding='utf-8', newl='\n', indent='', addindent=' ') 271 272 273def main(args: CommandlineArgs): 274 xml = generate_xml(args.fallback, args.aliases, args.families) 275 write_xml(args.outfile, xml) 276