blob: 38daebce8cafecc8007fa001aa10c6fbc58480f1 [file] [log] [blame]
#!/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)