xref: /aosp_15_r20/frameworks/base/data/fonts/script/xml_builder.py (revision d57664e9bc4670b3ecf6748a746a57c557b6bc9e)
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