blob: 38daebce8cafecc8007fa001aa10c6fbc58480f1 [file] [log] [blame]
Seigo Nonaka7e706b02024-08-17 19:16:35 +09001#!/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)