summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--data/fonts/script/Android.bp36
-rwxr-xr-xdata/fonts/script/alias_builder.py64
-rwxr-xr-xdata/fonts/script/commandline.py71
-rwxr-xr-xdata/fonts/script/custom_json.py31
-rwxr-xr-xdata/fonts/script/fallback_builder.py61
-rwxr-xr-xdata/fonts/script/family_builder.py112
-rwxr-xr-xdata/fonts/script/font_builder.py114
-rwxr-xr-xdata/fonts/script/generate_fonts_xml_main.py112
-rw-r--r--data/fonts/script/test/Android.bp29
-rwxr-xr-xdata/fonts/script/test/test_alias_builder.py92
-rwxr-xr-xdata/fonts/script/test/test_commandline.py100
-rwxr-xr-xdata/fonts/script/test/test_custom_json.py48
-rwxr-xr-xdata/fonts/script/test/test_fallback_builder.py78
-rwxr-xr-xdata/fonts/script/test/test_family_builder.py241
-rwxr-xr-xdata/fonts/script/test/test_font_builder.py379
-rwxr-xr-xdata/fonts/script/test/test_main.py44
-rwxr-xr-xdata/fonts/script/test/test_xml_builder.py344
-rwxr-xr-xdata/fonts/script/validators.py99
-rwxr-xr-xdata/fonts/script/xml_builder.py275
19 files changed, 2330 insertions, 0 deletions
diff --git a/data/fonts/script/Android.bp b/data/fonts/script/Android.bp
new file mode 100644
index 000000000000..34862859a311
--- /dev/null
+++ b/data/fonts/script/Android.bp
@@ -0,0 +1,36 @@
+// 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.
+
+python_library_host {
+ name: "generate_fonts_xml_lib",
+ srcs: [
+ "alias_builder.py",
+ "commandline.py",
+ "custom_json.py",
+ "fallback_builder.py",
+ "family_builder.py",
+ "font_builder.py",
+ "validators.py",
+ "xml_builder.py",
+ ],
+}
+
+python_binary_host {
+ name: "generate_fonts_xml",
+ main: "generate_fonts_xml_main.py",
+ srcs: ["generate_fonts_xml_main.py"],
+ libs: [
+ "generate_fonts_xml_lib",
+ ],
+}
diff --git a/data/fonts/script/alias_builder.py b/data/fonts/script/alias_builder.py
new file mode 100755
index 000000000000..cfc5d672752e
--- /dev/null
+++ b/data/fonts/script/alias_builder.py
@@ -0,0 +1,64 @@
+#!/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 Alias instance with validating JSON contents."""
+
+import dataclasses
+
+from custom_json import _load_json_with_comment
+from validators import check_str
+from validators import check_weight_or_none
+
+
+@dataclasses.dataclass
+class Alias:
+ name: str
+ to: str
+ weight: int | None
+
+
+_ALIAS_KEYS = set(["name", "to", "weight"])
+
+
+def parse_alias(obj) -> Alias:
+ """Convert given dict object to Alias instance."""
+ unknown_keys = obj.keys() - _ALIAS_KEYS
+ assert not unknown_keys, "Unknown keys found: %s" % unknown_keys
+ alias = Alias(
+ name=check_str(obj, "name"),
+ to=check_str(obj, "to"),
+ weight=check_weight_or_none(obj, "weight"),
+ )
+
+ assert alias.name != alias.to, "name and to must not be equal"
+
+ return alias
+
+
+def parse_alias_from_json(json_str) -> Alias:
+ """For testing purposes."""
+ return parse_alias(_load_json_with_comment(json_str))
+
+
+def parse_aliases(objs) -> [Alias]:
+ assert isinstance(objs, list), "aliases must be list"
+ return [parse_alias(obj) for obj in objs]
+
+
+def parse_aliases_from_json(json_str) -> [Alias]:
+ return parse_aliases(_load_json_with_comment(json_str))
diff --git a/data/fonts/script/commandline.py b/data/fonts/script/commandline.py
new file mode 100755
index 000000000000..743b1b2292b0
--- /dev/null
+++ b/data/fonts/script/commandline.py
@@ -0,0 +1,71 @@
+#!/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 commandline arguments."""
+
+import argparse
+import dataclasses
+from typing import Callable
+
+from alias_builder import Alias
+from alias_builder import parse_aliases_from_json
+from fallback_builder import FallbackEntry
+from fallback_builder import parse_fallback_from_json
+from family_builder import Family
+from family_builder import parse_families_from_json
+
+
+@dataclasses.dataclass
+class CommandlineArgs:
+ outfile: str
+ fallback: [FallbackEntry]
+ aliases: [Alias]
+ families: [Family]
+
+
+def _create_argument_parser() -> argparse.ArgumentParser:
+ """Create argument parser."""
+ parser = argparse.ArgumentParser()
+ parser.add_argument('-o', '--output')
+ parser.add_argument('--alias')
+ parser.add_argument('--fallback')
+ return parser
+
+
+def _fileread(path: str) -> str:
+ with open(path, 'r') as f:
+ return f.read()
+
+
+def parse_commandline(
+ args: [str], fileread: Callable[str, str] = _fileread
+) -> CommandlineArgs:
+ """Parses command line arguments and returns CommandlineArg."""
+ parser = _create_argument_parser()
+ args, inputs = parser.parse_known_args(args)
+
+ families = []
+ for i in inputs:
+ families = families + parse_families_from_json(fileread(i))
+
+ return CommandlineArgs(
+ outfile=args.output,
+ fallback=parse_fallback_from_json(fileread(args.fallback)),
+ aliases=parse_aliases_from_json(fileread(args.alias)),
+ families=families,
+ )
diff --git a/data/fonts/script/custom_json.py b/data/fonts/script/custom_json.py
new file mode 100755
index 000000000000..8a07bb523dda
--- /dev/null
+++ b/data/fonts/script/custom_json.py
@@ -0,0 +1,31 @@
+#!/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.
+#
+
+"""A custom json parser that additionally supports line comments."""
+
+import json
+import re
+
+# RegEx of removing line comment line in JSON.
+_LINE_COMMENT_RE = re.compile(r'\/\/[^\n\r]*[\n\r]')
+
+
+def _load_json_with_comment(json_str: str):
+ """Parse JSON string with accepting line comment."""
+ raw_text = re.sub(_LINE_COMMENT_RE, '', json_str)
+ return json.loads(raw_text)
diff --git a/data/fonts/script/fallback_builder.py b/data/fonts/script/fallback_builder.py
new file mode 100755
index 000000000000..2b66740cf352
--- /dev/null
+++ b/data/fonts/script/fallback_builder.py
@@ -0,0 +1,61 @@
+#!/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 Fallback instance with validating JSON contents."""
+
+import dataclasses
+
+from custom_json import _load_json_with_comment
+from validators import check_str_or_none
+
+
+@dataclasses.dataclass
+class FallbackEntry:
+ lang: str | None
+ id: str | None
+
+
+_FALLBACK_KEYS = set(["lang", "id"])
+
+
+def _parse_entry(obj) -> FallbackEntry:
+ """Convert given dict object to FallbackEntry instance."""
+ unknown_keys = obj.keys() - _FALLBACK_KEYS
+ assert not unknown_keys, "Unknown keys found: %s" % unknown_keys
+ entry = FallbackEntry(
+ lang=check_str_or_none(obj, "lang"),
+ id=check_str_or_none(obj, "id"),
+ )
+
+ assert entry.lang or entry.id, "lang or id must be specified."
+ assert (
+ not entry.lang or not entry.id
+ ), "lang and id must not be specified at the same time"
+
+ return entry
+
+
+def parse_fallback(objs) -> [FallbackEntry]:
+ assert isinstance(objs, list), "fallback must be list"
+ assert objs, "at least one etnry must be specified"
+ return [_parse_entry(obj) for obj in objs]
+
+
+def parse_fallback_from_json(json_str) -> [FallbackEntry]:
+ """For testing purposes."""
+ return parse_fallback(_load_json_with_comment(json_str))
diff --git a/data/fonts/script/family_builder.py b/data/fonts/script/family_builder.py
new file mode 100755
index 000000000000..9a6f8c553839
--- /dev/null
+++ b/data/fonts/script/family_builder.py
@@ -0,0 +1,112 @@
+#!/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 Family instance with validating JSON contents."""
+
+import dataclasses
+
+from custom_json import _load_json_with_comment
+from font_builder import Font
+from font_builder import parse_fonts
+from validators import check_enum_or_none
+from validators import check_priority_or_none
+from validators import check_str_or_none
+
+_FAMILY_KEYS = set([
+ "id",
+ "lang",
+ "name",
+ "variant",
+ "fallbackFor",
+ "fonts",
+ "target",
+ "priority",
+])
+
+
+@dataclasses.dataclass
+class Family:
+ id: str | None
+ lang: str | None
+ name: str | None
+ priority: int | None
+ variant: str | None
+ fallback_for: str | None
+ target: str | None
+ fonts: [Font]
+
+
+def _validate_family(family):
+ assert not family.lang or not family.name, (
+ "If lang attribute is specified, name attribute must not be specified: %s"
+ % family
+ )
+
+ if family.fallback_for:
+ assert family.target, (
+ "If fallbackFor is specified, must specify target: %s" % family
+ )
+ if family.target:
+ assert family.fallback_for, (
+ "If target is specified, must specify fallbackFor: %s" % family
+ )
+
+
+def _parse_family(obj, for_sanitization_test=False) -> Family:
+ """Create Family object from dictionary."""
+ unknown_keys = obj.keys() - _FAMILY_KEYS
+ assert not unknown_keys, "Unknown keys found: %s in %s" % (unknown_keys, obj)
+
+ if for_sanitization_test:
+ fonts = []
+ else:
+ fonts = parse_fonts(obj.get("fonts"))
+
+ family = Family(
+ id=check_str_or_none(obj, "id"),
+ lang=check_str_or_none(obj, "lang"),
+ name=check_str_or_none(obj, "name"),
+ priority=check_priority_or_none(obj, "priority"),
+ variant=check_enum_or_none(obj, "variant", ["elegant", "compact"]),
+ fallback_for=check_str_or_none(obj, "fallbackFor"),
+ target=check_str_or_none(obj, "target"),
+ fonts=fonts,
+ )
+
+ if not for_sanitization_test:
+ _validate_family(family)
+ return family
+
+
+def parse_family_from_json_for_sanitization_test(json_str) -> Family:
+ """For testing purposes."""
+ return _parse_family(
+ _load_json_with_comment(json_str), for_sanitization_test=True
+ )
+
+
+def parse_family_from_json(json_str) -> Family:
+ """For testing purposes."""
+ return _parse_family(_load_json_with_comment(json_str))
+
+
+def parse_families_from_json(json_str) -> [Family]:
+ objs = _load_json_with_comment(json_str)
+ assert isinstance(objs, list), "families must be list"
+ assert objs, "families must contains at least one family"
+ return [_parse_family(obj) for obj in objs]
diff --git a/data/fonts/script/font_builder.py b/data/fonts/script/font_builder.py
new file mode 100755
index 000000000000..f0fe966c50ab
--- /dev/null
+++ b/data/fonts/script/font_builder.py
@@ -0,0 +1,114 @@
+#!/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 Font instance with validating JSON contents."""
+
+import dataclasses
+
+from custom_json import _load_json_with_comment
+from validators import check_enum_or_none
+from validators import check_float
+from validators import check_int_or_none
+from validators import check_str
+from validators import check_str_or_none
+from validators import check_tag
+from validators import check_weight_or_none
+
+
+@dataclasses.dataclass
+class Font:
+ file: str
+ weight: int | None
+ style: str | None
+ index: int | None
+ supported_axes: str | None
+ post_script_name: str | None
+ axes: dict[str | float]
+
+
+_FONT_KEYS = set([
+ "file",
+ "weight",
+ "style",
+ "index",
+ "supportedAxes",
+ "postScriptName",
+ "axes",
+])
+
+
+def _check_axes(axes) -> dict[str | float] | None:
+ """Sanitize the variation axes."""
+ if axes is None:
+ return None
+ assert isinstance(axes, dict), "axes must be dict"
+
+ sanitized = {}
+ for key in axes.keys():
+ sanitized[check_tag(key)] = check_float(axes, key)
+
+ return sanitized
+
+
+def _parse_font(obj, for_sanitization_test=False) -> Font:
+ """Convert given dict object to Font instance."""
+ unknown_keys = obj.keys() - _FONT_KEYS
+ assert not unknown_keys, "Unknown keys found: %s" % unknown_keys
+ font = Font(
+ file=check_str(obj, "file"),
+ weight=check_weight_or_none(obj, "weight"),
+ style=check_enum_or_none(obj, "style", ["normal", "italic"]),
+ index=check_int_or_none(obj, "index"),
+ supported_axes=check_enum_or_none(
+ obj, "supportedAxes", ["wght", "wght,ital"]
+ ),
+ post_script_name=check_str_or_none(obj, "postScriptName"),
+ axes=_check_axes(obj.get("axes")),
+ )
+
+ if not for_sanitization_test:
+ assert font.file, "file must be specified"
+ if not font.supported_axes:
+ assert font.weight, (
+ "If supported_axes is not specified, weight should be specified: %s"
+ % obj
+ )
+ assert font.style, (
+ "If supported_axes is not specified, style should be specified: %s"
+ % obj
+ )
+
+ return font
+
+
+def parse_fonts(objs) -> Font:
+ assert isinstance(objs, list), "fonts must be list: %s" % (objs)
+ assert objs, "At least one font should be added."
+ return [_parse_font(obj) for obj in objs]
+
+
+def parse_font_from_json_for_sanitization_test(json_str: str) -> Font:
+ """For testing purposes."""
+ return _parse_font(
+ _load_json_with_comment(json_str), for_sanitization_test=False
+ )
+
+
+def parse_fonts_from_json_for_validation_test(json_str: str) -> [Font]:
+ """For testing purposes."""
+ return parse_fonts(_load_json_with_comment(json_str))
diff --git a/data/fonts/script/generate_fonts_xml_main.py b/data/fonts/script/generate_fonts_xml_main.py
new file mode 100755
index 000000000000..2f97708c5647
--- /dev/null
+++ b/data/fonts/script/generate_fonts_xml_main.py
@@ -0,0 +1,112 @@
+#!/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.
+#
+
+"""A main module for generating XML from font config JSONs.
+
+The following is a JSON format of the font configuration.
+
+[ // Top level element is a list to be able to hold multiple families
+ { // Dict for defining single family entry
+
+ // Optional String: unique identifier.
+ // This can be used for identifying this family instance.
+ // Currently this is ued only for specifying the target of the fallback
+ // family.
+ "id": "Roboto",
+
+ // Optional String: name of this family if this family creates a new
+ // fallback. If multiple families define the same name, it is a build
+ // error.
+ "name": "sans-serif",
+
+ // Optional String: language tag of this family if this family is a
+ // fallback family. Only language tags declared in fallback_order.json
+ // can be used. Specifying unknown language tags is a build error.
+ "lang": "und-Latn",
+
+ // Optional String: variant of the family
+ // Currently only “compact”, “elegant” are supported.
+ "variant": "compact",
+
+ // Optional String: specify the fallback target used for this family.
+ // If this key is specified, "target" attribute must also be specified.
+ // If this key is specified, "name" and "lang" must not be specified.
+ // If the specified fallback target is not defined, it is a build error.
+ "fallbackFor": "roboto-flex",
+
+ // Optional String: specify the family target to include this family.
+ // If this key is specified, "fallbackFor" attribute must also be
+ // specified. If this key is specified, "name" and "lang" must not be
+ // specified. If the specified family target is not defined, it is a
+ // build error.
+ "target": "RobotoMain",
+
+ // Optional Integer: specify the priority of the family.
+ // The priority order is determined by fallback_order.json.
+ // This priority is only used when two or more font families are
+ // assigned to the same rank: e.g. NotoColorEmoji.ttf and
+ // NotoColorEmojiFlags.ttf.
+ // All families have priority 0 by default and any value from -100 to
+ // 100 is valid. Lowering priority value increases the priority.
+ "priority": 0,
+
+ // Mandatory List: specify list of fonts. At least one font is required.
+ "fonts": [
+ { // Dict for defining a single font entry.
+
+ // Mandatory String: specify font file name in the system.
+ // This must be the file name in the system image.
+ "file": "Roboto-Regular.ttf",
+
+ // Optional String: specify the PostScript name of the font.
+ // This can be optional if the filename without extension is the
+ // same as the PostScript name.
+ "postScriptName": "Roboto",
+
+ // Optional String: specify weight of the font.
+ "weight": "100",
+
+ // Optional String: specify style of the font.
+ // Currently, only "normal" or "italic" is supported.
+ "style": "normal",
+
+ // Optional String: specify supported axes for automatic
+ // adjustment. Currently, only "wght" or "wght,ital" is
+ // supported.
+ "supportedAxes": "wght"
+
+ // Optional Dict: specify variation settings for this font.
+ "axes": {
+ // Optional key to float dictionaty entry for speicying axis
+ // values.
+ "wdth": 100.0,
+ }
+ },
+ ]
+ }
+]
+"""
+
+import sys
+
+from commandline import parse_commandline
+from xml_builder import main
+
+if __name__ == "__main__":
+ args = parse_commandline(sys.argv[1:])
+ main(args)
diff --git a/data/fonts/script/test/Android.bp b/data/fonts/script/test/Android.bp
new file mode 100644
index 000000000000..ff1ba4ce8dc6
--- /dev/null
+++ b/data/fonts/script/test/Android.bp
@@ -0,0 +1,29 @@
+// 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.
+
+package {
+ default_team: "trendy_team_android_text",
+}
+
+python_test_host {
+ name: "generate_fonts_xml_test",
+ main: "test_main.py",
+ srcs: [
+ "test_*.py",
+ ],
+ libs: ["generate_fonts_xml_lib"],
+ test_options: {
+ unit_test: true,
+ },
+}
diff --git a/data/fonts/script/test/test_alias_builder.py b/data/fonts/script/test/test_alias_builder.py
new file mode 100755
index 000000000000..c8ce961a5e10
--- /dev/null
+++ b/data/fonts/script/test/test_alias_builder.py
@@ -0,0 +1,92 @@
+#!/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.
+#
+
+import sys
+import unittest
+
+from alias_builder import parse_alias_from_json
+
+
+class AliasBuilderTest(unittest.TestCase):
+
+ def test_parse_alias_invalid_name(self):
+ self.assertRaises(
+ AssertionError, parse_alias_from_json, """{ "name": [], "to": "to" }"""
+ )
+ self.assertRaises(
+ AssertionError, parse_alias_from_json, """{ "name": 1, "to": "to" }"""
+ )
+ self.assertRaises(
+ AssertionError, parse_alias_from_json, """{ "name": 0.5, "to": "to" }"""
+ )
+
+ def test_parse_alias_invalid_to(self):
+ self.assertRaises(
+ AssertionError,
+ parse_alias_from_json,
+ """{ "name": "name", "to": [] }""",
+ )
+ self.assertRaises(
+ AssertionError, parse_alias_from_json, """{ "name": "name", "to": 1 }"""
+ )
+ self.assertRaises(
+ AssertionError,
+ parse_alias_from_json,
+ """{ "name": "name", "to": 0.4 }""",
+ )
+
+ def test_parse_alias_invalid_id(self):
+ self.assertRaises(
+ AssertionError,
+ parse_alias_from_json,
+ """{ "name": "name", "to": "to", "weight": [] }""",
+ )
+
+ def test_parse_alias_invalid_to(self):
+ self.assertRaises(
+ AssertionError,
+ parse_alias_from_json,
+ """{ "name": "name", "to": "name", "weight": [] }""",
+ )
+
+ def test_parse_alias(self):
+ alias = parse_alias_from_json("""
+ {
+ "name": "arial",
+ "to": "sans-serif"
+ }""")
+
+ self.assertEqual("arial", alias.name)
+ self.assertEqual("sans-serif", alias.to)
+ self.assertIsNone(alias.weight)
+
+ def test_parse_alias2(self):
+ alias = parse_alias_from_json("""
+ {
+ "name": "sans-serif-thin",
+ "to": "sans-serif",
+ "weight": 100
+ }""")
+
+ self.assertEqual("sans-serif-thin", alias.name)
+ self.assertEqual("sans-serif", alias.to)
+ self.assertEqual(100, alias.weight)
+
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)
diff --git a/data/fonts/script/test/test_commandline.py b/data/fonts/script/test/test_commandline.py
new file mode 100755
index 000000000000..75318cc10e68
--- /dev/null
+++ b/data/fonts/script/test/test_commandline.py
@@ -0,0 +1,100 @@
+#!/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.
+#
+
+import functools
+import sys
+import unittest
+
+import commandline
+
+
+class CommandlineTest(unittest.TestCase):
+
+ def fileread(filemap, path):
+ return filemap[path]
+
+ def test_commandline(self):
+ filemap = {}
+ filemap["aliases.json"] = (
+ """[{"name": "sans-serif-thin", "to": "sans-serif", "weight": 100}]"""
+ )
+ filemap["fallbacks.json"] = (
+ """[{"lang": "und-Arab"},{"lang": "und-Ethi"}]"""
+ )
+ filemap["family.json"] = """[{
+ "name": "sans-serif",
+ "fonts": [{
+ "file": "Roboto-Regular.ttf",
+ "supportedAxes": "wght,ital",
+ "axes": { "wdth": "100" }
+ }]
+ }, {
+ "name": "sans-serif-condensed",
+ "fonts": [{
+ "file": "Roboto-Regular.ttf",
+ "supportedAxes": "wght,ital",
+ "axes": { "wdth": "75" }
+ }]
+ }]"""
+
+ filemap["family2.json"] = """[{
+ "name": "roboto-flex",
+ "fonts": [{
+ "file": "RobotoFlex-Regular.ttf",
+ "supportedAxes": "wght",
+ "axes": { "wdth": "100" }
+ }]
+ }]"""
+
+ args = commandline.parse_commandline(
+ [
+ "-o",
+ "output.xml",
+ "--alias",
+ "aliases.json",
+ "--fallback",
+ "fallbacks.json",
+ "family.json",
+ "family2.json",
+ ],
+ functools.partial(CommandlineTest.fileread, filemap),
+ )
+
+ self.assertEquals("output.xml", args.outfile)
+
+ self.assertEquals(1, len(args.aliases))
+ self.assertEquals("sans-serif-thin", args.aliases[0].name)
+ self.assertEquals("sans-serif", args.aliases[0].to)
+ self.assertEquals(100, args.aliases[0].weight)
+
+ self.assertEquals(2, len(args.fallback))
+ # Order is not a part of expectation. Check the expected lang is included.
+ langs = set(["und-Arab", "und-Ethi"])
+ self.assertTrue(args.fallback[0].lang in langs)
+ self.assertTrue(args.fallback[1].lang in langs)
+
+ self.assertEquals(3, len(args.families))
+ # Order is not a part of expectation. Check the expected name is included.
+ names = set(["sans-serif", "sans-serif-condensed", "roboto-flex"])
+ self.assertTrue(args.families[0].name in names)
+ self.assertTrue(args.families[1].name in names)
+ self.assertTrue(args.families[2].name in names)
+
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)
diff --git a/data/fonts/script/test/test_custom_json.py b/data/fonts/script/test/test_custom_json.py
new file mode 100755
index 000000000000..64586b46aeae
--- /dev/null
+++ b/data/fonts/script/test/test_custom_json.py
@@ -0,0 +1,48 @@
+#!/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.
+#
+
+import sys
+import tempfile
+import unittest
+
+from custom_json import _load_json_with_comment
+
+
+class JsonParseTest(unittest.TestCase):
+
+ def test_json_with_comment(self):
+ self.assertEqual(
+ [],
+ _load_json_with_comment("""
+ // The line comment can be used in font JSON configuration.
+ []
+ """),
+ )
+
+ def test_json_with_comment_double_line_comment(self):
+ self.assertEqual(
+ [],
+ _load_json_with_comment("""
+ // The double line comment // should work.
+ []
+ """),
+ )
+
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)
diff --git a/data/fonts/script/test/test_fallback_builder.py b/data/fonts/script/test/test_fallback_builder.py
new file mode 100755
index 000000000000..1f6b6000e5cd
--- /dev/null
+++ b/data/fonts/script/test/test_fallback_builder.py
@@ -0,0 +1,78 @@
+#!/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.
+#
+
+import sys
+import unittest
+
+from fallback_builder import parse_fallback_from_json
+
+
+class FallbackBuilderTest(unittest.TestCase):
+
+ def test_parse_fallback_invalid_lang(self):
+ self.assertRaises(
+ AssertionError, parse_fallback_from_json, """[{ "lang": [] }]"""
+ )
+ self.assertRaises(
+ AssertionError, parse_fallback_from_json, """[{ "lang": 1 }]"""
+ )
+ self.assertRaises(
+ AssertionError, parse_fallback_from_json, """[{ "lang": 0.5 }]"""
+ )
+
+ def test_parse_fallback_invalid_id(self):
+ self.assertRaises(
+ AssertionError, parse_fallback_from_json, """[{ "id": [] }]"""
+ )
+ self.assertRaises(
+ AssertionError, parse_fallback_from_json, """[{ "id": 1 }]"""
+ )
+ self.assertRaises(
+ AssertionError, parse_fallback_from_json, """[{ "id": 0.5 }]"""
+ )
+
+ def test_parse_fallback_invalid(self):
+ self.assertRaises(
+ AssertionError,
+ parse_fallback_from_json,
+ """[{ "lang": "ja", "id": "Roboto-Regular.ttf" }]""",
+ )
+ self.assertRaises(AssertionError, parse_fallback_from_json, """[]""")
+ self.assertRaises(AssertionError, parse_fallback_from_json, """[{}]""")
+
+ def test_parse_fallback(self):
+ fallback = parse_fallback_from_json("""[
+ { "lang": "und-Arab" },
+ { "id": "NotoSansSymbols-Regular-Subsetted.ttf" },
+ { "lang": "ja" }
+ ]""")
+
+ self.assertEqual(3, len(fallback))
+
+ self.assertEqual("und-Arab", fallback[0].lang)
+ self.assertIsNone(fallback[0].id)
+
+ self.assertIsNone(fallback[1].lang)
+ self.assertEqual("NotoSansSymbols-Regular-Subsetted.ttf", fallback[1].id)
+
+ self.assertEqual("ja", fallback[2].lang)
+ self.assertIsNone(fallback[2].id)
+
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)
diff --git a/data/fonts/script/test/test_family_builder.py b/data/fonts/script/test/test_family_builder.py
new file mode 100755
index 000000000000..5b20cee60026
--- /dev/null
+++ b/data/fonts/script/test/test_family_builder.py
@@ -0,0 +1,241 @@
+#!/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.
+#
+
+import sys
+import unittest
+
+from family_builder import parse_family_from_json
+from family_builder import parse_family_from_json_for_sanitization_test
+
+_VALID_FONT_JSON = """[{ "file": "a.ttf", "weight": 400, "style": "normal" }]"""
+
+
+class FamilyBuilderTest(unittest.TestCase):
+
+ def test_parse_family_invalid_id(self):
+ self.assertRaises(
+ AssertionError,
+ parse_family_from_json_for_sanitization_test,
+ """{ "id": [] }""",
+ )
+ self.assertRaises(
+ AssertionError,
+ parse_family_from_json_for_sanitization_test,
+ """{ "id": 1 }""",
+ )
+ self.assertRaises(
+ AssertionError,
+ parse_family_from_json_for_sanitization_test,
+ """{ "id": 0.5 }""",
+ )
+
+ def test_parse_family_invalid_lang(self):
+ self.assertRaises(
+ AssertionError,
+ parse_family_from_json_for_sanitization_test,
+ """{ "lang": [] }""",
+ )
+ self.assertRaises(
+ AssertionError,
+ parse_family_from_json_for_sanitization_test,
+ """{ "lang": 1 }""",
+ )
+ self.assertRaises(
+ AssertionError,
+ parse_family_from_json_for_sanitization_test,
+ """{ "lang": 0.5 }""",
+ )
+
+ def test_parse_family_invalid_name(self):
+ self.assertRaises(
+ AssertionError,
+ parse_family_from_json_for_sanitization_test,
+ """{ "name": [] }""",
+ )
+ self.assertRaises(
+ AssertionError,
+ parse_family_from_json_for_sanitization_test,
+ """{ "name": 1 }""",
+ )
+ self.assertRaises(
+ AssertionError,
+ parse_family_from_json_for_sanitization_test,
+ """{ "name": 0.5 }""",
+ )
+
+ def test_parse_family_invalid_variant(self):
+ self.assertRaises(
+ AssertionError,
+ parse_family_from_json_for_sanitization_test,
+ """{ "variant": [] }""",
+ )
+ self.assertRaises(
+ AssertionError,
+ parse_family_from_json_for_sanitization_test,
+ """{ "variant": 1 }""",
+ )
+ self.assertRaises(
+ AssertionError,
+ parse_family_from_json_for_sanitization_test,
+ """{ "variant": 0.5 }""",
+ )
+ self.assertRaises(
+ AssertionError,
+ parse_family_from_json_for_sanitization_test,
+ """{ "variant": "default" }""",
+ )
+
+ def test_parse_family_invalid_fallback_for(self):
+ self.assertRaises(
+ AssertionError,
+ parse_family_from_json_for_sanitization_test,
+ """{ "fallbackFor": [] }""",
+ )
+ self.assertRaises(
+ AssertionError,
+ parse_family_from_json_for_sanitization_test,
+ """{ "fallbackFor": 1 }""",
+ )
+ self.assertRaises(
+ AssertionError,
+ parse_family_from_json_for_sanitization_test,
+ """{ "name": 0.5 }""",
+ )
+
+ def test_parse_invalid_family(self):
+ # fallbackFor and target should be specified altogether
+ self.assertRaises(
+ AssertionError,
+ parse_family_from_json,
+ """{ "fallbackFor": "serif", "fonts": %s } """ % _VALID_FONT_JSON,
+ )
+ self.assertRaises(
+ AssertionError,
+ parse_family_from_json,
+ """{ "target": "Roboto", "fonts": %s } """ % _VALID_FONT_JSON,
+ )
+
+ # Invalid fonts
+ self.assertRaises(AssertionError, parse_family_from_json, """{} """)
+ self.assertRaises(
+ AssertionError,
+ parse_family_from_json,
+ """{ "fonts": [] } """,
+ )
+ self.assertRaises(
+ AssertionError,
+ parse_family_from_json,
+ """{ "fonts": {} } """,
+ )
+
+ def test_parse_family(self):
+ family = parse_family_from_json("""
+ {
+ "lang": "und-Arab",
+ "variant": "compact",
+ "fonts": [{
+ "file": "NotoNaskhArabicUI-Regular.ttf",
+ "postScriptName": "NotoNaskhArabicUI",
+ "weight": "400",
+ "style": "normal"
+ }, {
+ "file": "NotoNaskhArabicUI-Bold.ttf",
+ "weight": "700",
+ "style": "normal"
+ }]
+ }""")
+
+ self.assertEqual("und-Arab", family.lang)
+ self.assertEqual("compact", family.variant)
+ self.assertEqual(2, len(family.fonts))
+ self.assertIsNone(family.id)
+ self.assertIsNone(family.name)
+ self.assertIsNone(family.fallback_for)
+ self.assertIsNone(family.target)
+
+ def test_parse_family2(self):
+ family = parse_family_from_json("""
+ {
+ "id": "NotoSansCJK_zh-Hans",
+ "lang": "zh-Hans",
+ "fonts": [{
+ "file": "NotoSansCJK-Regular.ttc",
+ "postScriptName": "NotoSansCJKJP-Regular",
+ "weight": "400",
+ "style": "normal",
+ "supportedAxes": "wght",
+ "axes": {
+ "wght": "400"
+ },
+ "index": "2"
+ }]
+ }""")
+
+ self.assertEqual("NotoSansCJK_zh-Hans", family.id)
+ self.assertEqual("zh-Hans", family.lang)
+ self.assertEqual(1, len(family.fonts))
+ self.assertIsNone(family.name)
+ self.assertIsNone(family.target)
+
+ def test_parse_family3(self):
+ family = parse_family_from_json("""
+ {
+ "lang": "zh-Hans",
+ "fonts": [{
+ "file": "NotoSerifCJK-Regular.ttc",
+ "postScriptName": "NotoSerifCJKjp-Regular",
+ "weight": "400",
+ "style": "normal",
+ "index": "2"
+ }],
+ "target": "NotoSansCJK_zh-Hans",
+ "fallbackFor": "serif"
+ }
+ """)
+
+ self.assertEqual("zh-Hans", family.lang)
+ self.assertEqual(1, len(family.fonts))
+ self.assertEqual("serif", family.fallback_for)
+ self.assertEqual("NotoSansCJK_zh-Hans", family.target)
+ self.assertIsNone(family.name)
+ self.assertIsNone(family.variant)
+
+ def test_parse_family4(self):
+ family = parse_family_from_json("""
+ {
+ "name": "sans-serif",
+ "fonts": [{
+ "file": "Roboto-Regular.ttf",
+ "supportedAxes": "wght,ital",
+ "axes": {
+ "wdth": "100"
+ }
+ }]
+ }
+ """)
+
+ self.assertEqual("sans-serif", family.name)
+ self.assertEqual(1, len(family.fonts))
+ self.assertIsNone(family.lang)
+ self.assertIsNone(family.fallback_for)
+ self.assertIsNone(family.target)
+ self.assertIsNone(family.variant)
+
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)
diff --git a/data/fonts/script/test/test_font_builder.py b/data/fonts/script/test/test_font_builder.py
new file mode 100755
index 000000000000..a114cd3b0c5f
--- /dev/null
+++ b/data/fonts/script/test/test_font_builder.py
@@ -0,0 +1,379 @@
+#!/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.
+#
+
+import json
+import sys
+import unittest
+
+from font_builder import parse_font_from_json_for_sanitization_test, parse_fonts_from_json_for_validation_test
+
+
+class FontBuilderTest(unittest.TestCase):
+
+ def test_parse_font_invalid_file(self):
+ # File must be string
+ self.assertRaises(
+ AssertionError,
+ parse_font_from_json_for_sanitization_test,
+ """{ "file": [] }""",
+ )
+ self.assertRaises(
+ AssertionError,
+ parse_font_from_json_for_sanitization_test,
+ """{ "file": -10 }""",
+ )
+ self.assertRaises(
+ AssertionError,
+ parse_font_from_json_for_sanitization_test,
+ """{ "file": 0.5 }""",
+ )
+
+ def test_parse_font_invalid_weight(self):
+ # Weight only accept integer or string as integer.
+ self.assertRaises(
+ AssertionError,
+ parse_font_from_json_for_sanitization_test,
+ """{ "weight": [] }""",
+ )
+ self.assertRaises(
+ AssertionError,
+ parse_font_from_json_for_sanitization_test,
+ """{ "weight": 0.5 }""",
+ )
+ self.assertRaises(
+ AssertionError,
+ parse_font_from_json_for_sanitization_test,
+ """{ "weight": "0.5" }""",
+ )
+ self.assertRaises(
+ AssertionError,
+ parse_font_from_json_for_sanitization_test,
+ """{ "weight": -10 }""",
+ )
+ self.assertRaises(
+ AssertionError,
+ parse_font_from_json_for_sanitization_test,
+ """{ "weight": 1001 }""",
+ )
+ self.assertRaises(
+ AssertionError,
+ parse_font_from_json_for_sanitization_test,
+ """{ "weight": "-10" }""",
+ )
+ self.assertRaises(
+ AssertionError,
+ parse_font_from_json_for_sanitization_test,
+ """{ "weight": "1001" }""",
+ )
+
+ def test_parse_font_invalid_style(self):
+ # Style only accept string "noromal" or "italic"
+ self.assertRaises(
+ AssertionError,
+ parse_font_from_json_for_sanitization_test,
+ """{ "style": [] }""",
+ )
+ self.assertRaises(
+ AssertionError,
+ parse_font_from_json_for_sanitization_test,
+ """{ "style": 0 }""",
+ )
+ self.assertRaises(
+ AssertionError,
+ parse_font_from_json_for_sanitization_test,
+ """{ "style": "foo" }""",
+ )
+
+ def test_parse_font_invalid_index(self):
+ # Index only accepts integer or string as integer that equals or larger than zero.
+ self.assertRaises(
+ AssertionError,
+ parse_font_from_json_for_sanitization_test,
+ """{ "index": [] }""",
+ )
+ self.assertRaises(
+ AssertionError,
+ parse_font_from_json_for_sanitization_test,
+ """{ "index": "foo" }""",
+ )
+ self.assertRaises(
+ AssertionError,
+ parse_font_from_json_for_sanitization_test,
+ """{ "index": -1 }""",
+ )
+ self.assertRaises(
+ AssertionError,
+ parse_font_from_json_for_sanitization_test,
+ """{ "index": "-1" }""",
+ )
+
+ def test_parse_font_invalid_supportedAxes(self):
+ # The supportedAxes only accepts wght or wght,ital.
+ self.assertRaises(
+ AssertionError,
+ parse_font_from_json_for_sanitization_test,
+ """{ "supportedAxes": [] }""",
+ )
+ self.assertRaises(
+ AssertionError,
+ parse_font_from_json_for_sanitization_test,
+ """{ "supportedAxes": 0 }""",
+ )
+ self.assertRaises(
+ AssertionError,
+ parse_font_from_json_for_sanitization_test,
+ """{ "supportedAxes": 0.5 }""",
+ )
+ self.assertRaises(
+ AssertionError,
+ parse_font_from_json_for_sanitization_test,
+ """{ "supportedAxes": "1" }""",
+ )
+ self.assertRaises(
+ AssertionError,
+ parse_font_from_json_for_sanitization_test,
+ """{ "supportedAxes": "ital" }""",
+ )
+ self.assertRaises(
+ AssertionError,
+ parse_font_from_json_for_sanitization_test,
+ """{ "supportedAxes": "wghtital" }""",
+ )
+
+ def test_parse_font_invalid_post_script_name(self):
+ # The postScriptName only accepts string.
+ self.assertRaises(
+ AssertionError,
+ parse_font_from_json_for_sanitization_test,
+ """{ "postScriptName": [] }""",
+ )
+ self.assertRaises(
+ AssertionError,
+ parse_font_from_json_for_sanitization_test,
+ """{ "postScriptName": 1 }""",
+ )
+ self.assertRaises(
+ AssertionError,
+ parse_font_from_json_for_sanitization_test,
+ """{ "postScriptName": 0.5 }""",
+ )
+
+ def test_parse_font_invalid_axes(self):
+ # The axes accept OpenType tag to float value.
+ self.assertRaises(
+ AssertionError,
+ parse_font_from_json_for_sanitization_test,
+ """{ "axes": [] }""",
+ )
+ self.assertRaises(
+ AssertionError,
+ parse_font_from_json_for_sanitization_test,
+ """{ "axes": "foo" }""",
+ )
+ self.assertRaises(
+ AssertionError,
+ parse_font_from_json_for_sanitization_test,
+ """{ "axes": 1 }""",
+ )
+ self.assertRaises(
+ AssertionError,
+ parse_font_from_json_for_sanitization_test,
+ """{
+ "axes":{
+ "wght": "ital"
+ }
+ }""",
+ )
+ self.assertRaises(
+ AssertionError,
+ parse_font_from_json_for_sanitization_test,
+ """{
+ "axes":{
+ "weight": 100
+ }
+ }""",
+ )
+
+ def test_parse_font_unknown_key(self):
+ self.assertRaises(
+ AssertionError,
+ parse_font_from_json_for_sanitization_test,
+ """{ "font": "Roboto-Regular.ttf" }""",
+ )
+
+ def test_parse_font_invalid_font(self):
+ # empty fonts are not allowed
+ self.assertRaises(
+ AssertionError, parse_fonts_from_json_for_validation_test, """[]"""
+ )
+ # At least file should be specified
+ self.assertRaises(
+ AssertionError, parse_fonts_from_json_for_validation_test, """[{}]"""
+ )
+ # If supportedAxes is not spccified, weight and style should be specified.
+ self.assertRaises(
+ AssertionError,
+ parse_fonts_from_json_for_validation_test,
+ """[{
+ "file": "Roboto-Regular.ttf",
+ "weight": 400
+ }]""",
+ )
+ self.assertRaises(
+ AssertionError,
+ parse_fonts_from_json_for_validation_test,
+ """[{
+ "file": "Roboto-Regular.ttf",
+ "style": "normal"
+ }]""",
+ )
+
+ def test_parse_font(self):
+ fonts = parse_fonts_from_json_for_validation_test("""[
+ {
+ "file": "Roboto-Regular.ttf",
+ "weight": 700,
+ "style": "normal",
+ "axes": {
+ "wght": 700
+ }
+ }, {
+ "file": "Roboto-Italic.ttf",
+ "weight": 700,
+ "style": "italic",
+ "axes": {
+ "wght": 700
+ }
+ }
+ ]""")
+ self.assertEqual(2, len(fonts))
+
+ self.assertEqual("Roboto-Regular.ttf", fonts[0].file)
+ self.assertEqual(700, fonts[0].weight)
+ self.assertEqual("normal", fonts[0].style)
+ self.assertEqual(1, len(fonts[0].axes))
+ self.assertEqual(700, fonts[0].axes["wght"])
+ self.assertIsNone(fonts[0].index)
+ self.assertIsNone(fonts[0].supported_axes)
+ self.assertIsNone(fonts[0].post_script_name)
+
+ self.assertEqual("Roboto-Italic.ttf", fonts[1].file)
+ self.assertEqual(700, fonts[1].weight)
+ self.assertEqual("italic", fonts[1].style)
+ self.assertEqual(1, len(fonts[1].axes))
+ self.assertEqual(700, fonts[1].axes["wght"])
+ self.assertIsNone(fonts[1].index)
+ self.assertIsNone(fonts[1].supported_axes)
+ self.assertIsNone(fonts[1].post_script_name)
+
+ def test_parse_font2(self):
+ fonts = parse_fonts_from_json_for_validation_test("""[
+ {
+ "file": "RobotoFlex-Regular.ttf",
+ "supportedAxes": "wght",
+ "axes": {
+ "wdth": 100
+ }
+ }
+ ]""")
+ self.assertEqual(1, len(fonts))
+
+ self.assertEqual("RobotoFlex-Regular.ttf", fonts[0].file)
+ self.assertEqual(1, len(fonts[0].axes))
+ self.assertEqual(100, fonts[0].axes["wdth"])
+ self.assertIsNone(fonts[0].index)
+ self.assertIsNone(fonts[0].weight)
+ self.assertIsNone(fonts[0].style)
+ self.assertIsNone(fonts[0].post_script_name)
+
+ def test_parse_font3(self):
+ fonts = parse_fonts_from_json_for_validation_test("""[
+ {
+ "file": "SourceSansPro-Regular.ttf",
+ "weight": 400,
+ "style": "normal"
+ }, {
+ "file": "SourceSansPro-Italic.ttf",
+ "weight": 400,
+ "style": "italic"
+ }, {
+ "file": "SourceSansPro-SemiBold.ttf",
+ "weight": 600,
+ "style": "normal"
+ }, {
+ "file": "SourceSansPro-SemiBoldItalic.ttf",
+ "weight": 600,
+ "style": "italic"
+ }, {
+ "file": "SourceSansPro-Bold.ttf",
+ "weight": 700,
+ "style": "normal"
+ }, {
+ "file": "SourceSansPro-BoldItalic.ttf",
+ "weight": 700,
+ "style": "italic"
+ }
+ ]""")
+
+ self.assertEqual(6, len(fonts))
+
+ self.assertEqual("SourceSansPro-Regular.ttf", fonts[0].file)
+ self.assertEqual(400, fonts[0].weight)
+ self.assertEqual("normal", fonts[0].style)
+
+ self.assertEqual("SourceSansPro-Italic.ttf", fonts[1].file)
+ self.assertEqual(400, fonts[1].weight)
+ self.assertEqual("italic", fonts[1].style)
+
+ self.assertEqual("SourceSansPro-SemiBold.ttf", fonts[2].file)
+ self.assertEqual(600, fonts[2].weight)
+ self.assertEqual("normal", fonts[2].style)
+
+ self.assertEqual("SourceSansPro-SemiBoldItalic.ttf", fonts[3].file)
+ self.assertEqual(600, fonts[3].weight)
+ self.assertEqual("italic", fonts[3].style)
+
+ self.assertEqual("SourceSansPro-Bold.ttf", fonts[4].file)
+ self.assertEqual(700, fonts[4].weight)
+ self.assertEqual("normal", fonts[4].style)
+
+ self.assertEqual("SourceSansPro-BoldItalic.ttf", fonts[5].file)
+ self.assertEqual(700, fonts[5].weight)
+ self.assertEqual("italic", fonts[5].style)
+
+ def test_parse_font4(self):
+ fonts = parse_fonts_from_json_for_validation_test("""[
+ {
+ "file": "NotoSerifCJK-Regular.ttc",
+ "postScriptName": "NotoSerifCJKjp-Regular",
+ "weight": "400",
+ "style": "normal",
+ "index": "2"
+ }
+ ]""")
+ self.assertEqual(1, len(fonts))
+
+ self.assertEqual("NotoSerifCJK-Regular.ttc", fonts[0].file)
+ self.assertEqual("NotoSerifCJKjp-Regular", fonts[0].post_script_name)
+ self.assertEqual(400, fonts[0].weight)
+ self.assertEqual("normal", fonts[0].style)
+ self.assertEqual(2, fonts[0].index)
+
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)
diff --git a/data/fonts/script/test/test_main.py b/data/fonts/script/test/test_main.py
new file mode 100755
index 000000000000..7a2a9dab6feb
--- /dev/null
+++ b/data/fonts/script/test/test_main.py
@@ -0,0 +1,44 @@
+#!/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.
+#
+
+import os
+import sys
+import tempfile
+import unittest
+
+import test_alias_builder
+import test_commandline
+import test_custom_json
+import test_fallback_builder
+import test_family_builder
+import test_font_builder
+import test_xml_builder
+
+if __name__ == "__main__":
+ loader = unittest.TestLoader()
+ # TODO: can we load all tests from the directory?
+ testsuite = unittest.suite.TestSuite()
+ testsuite.addTest(loader.loadTestsFromModule(test_alias_builder))
+ testsuite.addTest(loader.loadTestsFromModule(test_commandline))
+ testsuite.addTest(loader.loadTestsFromModule(test_custom_json))
+ testsuite.addTest(loader.loadTestsFromModule(test_fallback_builder))
+ testsuite.addTest(loader.loadTestsFromModule(test_family_builder))
+ testsuite.addTest(loader.loadTestsFromModule(test_font_builder))
+ testsuite.addTest(loader.loadTestsFromModule(test_xml_builder))
+ assert testsuite.countTestCases()
+ unittest.TextTestRunner(verbosity=2).run(testsuite)
diff --git a/data/fonts/script/test/test_xml_builder.py b/data/fonts/script/test/test_xml_builder.py
new file mode 100755
index 000000000000..24a033b43cbc
--- /dev/null
+++ b/data/fonts/script/test/test_xml_builder.py
@@ -0,0 +1,344 @@
+#!/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.
+#
+
+import random
+import sys
+import unittest
+
+from alias_builder import parse_aliases_from_json
+from commandline import CommandlineArgs
+from fallback_builder import parse_fallback_from_json
+from family_builder import parse_family_from_json
+from xml_builder import FallbackOrder
+from xml_builder import generate_xml
+
+_SANS_SERIF = parse_family_from_json("""{
+ "name": "sans-serif",
+ "fonts": [{
+ "file": "Roboto-Regular.ttf",
+ "supportedAxes": "wght,ital",
+ "axes": { "wdth": "100" }
+ }]
+}""")
+
+_SERIF = parse_family_from_json("""{
+ "name": "serif",
+ "fonts": [{
+ "file": "NotoSerif-Regular.ttf",
+ "postScriptName": "NotoSerif",
+ "weight": "400",
+ "style": "normal"
+ }, {
+ "file": "NotoSerif-Bold.ttf",
+ "weight": "700",
+ "style": "normal"
+ }, {
+ "file": "NotoSerif-Italic.ttf",
+ "weight": "400",
+ "style": "italic"
+ }, {
+ "file": "NotoSerif-BoldItalic.ttf",
+ "weight": "700",
+ "style": "italic"
+ }]
+}""")
+
+_ROBOTO_FLEX = parse_family_from_json("""{
+ "name": "roboto-flex",
+ "fonts": [{
+ "file": "RobotoFlex-Regular.ttf",
+ "supportedAxes": "wght",
+ "axes": { "wdth": "100" }
+ }]
+}""")
+
+_ARABIC = parse_family_from_json("""{
+ "lang": "und-Arab",
+ "variant": "elegant",
+ "fonts": [{
+ "file": "NotoNaskhArabic-Regular.ttf",
+ "postScriptName": "NotoNaskhArabic",
+ "weight": "400",
+ "style": "normal"
+ }, {
+ "file": "NotoNaskhArabic-Bold.ttf",
+ "weight": "700",
+ "style": "normal"
+ }]
+}""")
+
+_ARABIC_UI = parse_family_from_json("""{
+ "lang": "und-Arab",
+ "variant": "compact",
+ "fonts": [{
+ "file": "NotoNaskhArabicUI-Regular.ttf",
+ "postScriptName": "NotoNaskhArabicUI",
+ "weight": "400",
+ "style": "normal"
+ }, {
+ "file": "NotoNaskhArabicUI-Bold.ttf",
+ "weight": "700",
+ "style": "normal"
+ }]
+}""")
+
+_HANS = parse_family_from_json("""{
+ "lang": "zh-Hans",
+ "fonts": [{
+ "file": "NotoSansCJK-Regular.ttc",
+ "postScriptName": "NotoSansCJKJP-Regular",
+ "weight": "400",
+ "style": "normal",
+ "supportedAxes": "wght",
+ "axes": { "wght": "400" },
+ "index": "2"
+ }],
+ "id": "NotoSansCJK_zh-Hans"
+}""")
+
+_JA = parse_family_from_json("""{
+ "lang": "ja",
+ "fonts": [{
+ "file": "NotoSansCJK-Regular.ttc",
+ "postScriptName": "NotoSansCJKJP-Regular",
+ "weight": "400",
+ "style": "normal",
+ "supportedAxes": "wght",
+ "axes": { "wght": "400" },
+ "index": "0"
+ }],
+ "id": "NotoSansCJK_ja"
+}""")
+
+_JA_HENTAIGANA = parse_family_from_json("""{
+ "lang": "ja",
+ "priority": 100,
+ "fonts": [{
+ "file": "NotoSerifHentaigana.ttf",
+ "postScriptName": "NotoSerifHentaigana-ExtraLight",
+ "supportedAxes": "wght",
+ "axes": { "wght": "400" }
+ }]
+}""")
+
+_HANS_SERIF = parse_family_from_json("""{
+ "lang": "zh-Hans",
+ "fonts": [{
+ "file": "NotoSerifCJK-Regular.ttc",
+ "postScriptName": "NotoSerifCJKjp-Regular",
+ "weight": "400",
+ "style": "normal",
+ "index": "2"
+ }],
+ "fallbackFor": "serif",
+ "target": "NotoSansCJK_zh-Hans"
+}""")
+
+_JA_SERIF = parse_family_from_json("""{
+ "lang": "ja",
+ "fonts": [{
+ "file": "NotoSerifCJK-Regular.ttc",
+ "postScriptName": "NotoSerifCJKjp-Regular",
+ "weight": "400",
+ "style": "normal",
+ "index": "0"
+ }],
+ "fallbackFor": "serif",
+ "target": "NotoSansCJK_ja"
+}""")
+
+_FALLBACK = parse_fallback_from_json("""[
+ { "lang": "und-Arab" },
+ { "lang": "zh-Hans" },
+ { "lang": "ja" }
+]""")
+
+_ALIASES = parse_aliases_from_json("""[
+ {
+ "name": "sans-serif-thin",
+ "to" : "sans-serif",
+ "weight": 100
+ }
+]""")
+
+
+class FallbackOrderTest(unittest.TestCase):
+
+ def test_fallback_order(self):
+ order = FallbackOrder(_FALLBACK)
+
+ # Arabic and Arabic UI are prioritized over Simplified Chinese
+ self.assertTrue(order(_ARABIC) < order(_HANS))
+ self.assertTrue(order(_ARABIC_UI) < order(_HANS))
+
+ # Simplified Chinese is prioritized over Japanese
+ self.assertTrue(order(_HANS) < order(_JA))
+
+ def test_fallback_order_variant(self):
+ order = FallbackOrder(_FALLBACK)
+
+ # Arabic is prioritize over Arabic UI
+ self.assertTrue(order(_ARABIC) < order(_ARABIC_UI))
+
+ def test_fallback_order_unknown_priority(self):
+ order = FallbackOrder(parse_fallback_from_json("""[
+ { "lang": "zh-Hans" }
+ ]"""))
+
+ self.assertRaises(AssertionError, order, _ARABIC)
+
+ def test_fallback_order_id_and_lang(self):
+ order = FallbackOrder(_FALLBACK)
+
+ # If both ID and lang matches the fallback, the ID is used.
+ self.assertTrue(order(_HANS) < order(_JA))
+
+
+class XmlBuilderTest(unittest.TestCase):
+
+ def test_no_duplicate_families(self):
+ self.assertRaises(
+ AssertionError,
+ generate_xml,
+ fallback=_FALLBACK,
+ aliases=[],
+ families=[_SANS_SERIF, _ROBOTO_FLEX, _ROBOTO_FLEX],
+ )
+
+ def test_mandatory_sans_serif(self):
+ self.assertRaises(
+ AssertionError,
+ generate_xml,
+ fallback=_FALLBACK,
+ aliases=[],
+ families=[_ARABIC, _ARABIC_UI, _HANS, _JA],
+ )
+
+ def test_missing_fallback_target(self):
+ # serif family is necessary for fallback.
+ self.assertRaises(
+ AssertionError,
+ generate_xml,
+ fallback=_FALLBACK,
+ aliases=[],
+ families=[_SANS_SERIF, _HANS_SERIF],
+ )
+
+ # target family is necessary for fallback.
+ self.assertRaises(
+ AssertionError,
+ generate_xml,
+ fallback=_FALLBACK,
+ aliases=[],
+ families=[_SANS_SERIF, _SERIF, _HANS_SERIF],
+ )
+
+ def test_missing_alias_target(self):
+ self.assertRaises(
+ AssertionError,
+ generate_xml,
+ fallback=_FALLBACK,
+ aliases=parse_aliases_from_json("""[{
+ "name": "serif-thin",
+ "to" : "serif",
+ "weight": 100
+ }]"""),
+ families=[_SANS_SERIF, _HANS_SERIF],
+ )
+
+ def test_duplicated_alias(self):
+ self.assertRaises(
+ AssertionError,
+ generate_xml,
+ fallback=_FALLBACK,
+ aliases=parse_aliases_from_json("""[{
+ "name": "serif-thin",
+ "to" : "serif",
+ "weight": 100
+ },{
+ "name": "serif-thin",
+ "to" : "serif",
+ "weight": 100
+ }]"""),
+ families=[_SANS_SERIF, _SERIF, _HANS_SERIF],
+ )
+
+ def test_same_priority(self):
+ self.assertRaises(
+ AssertionError,
+ generate_xml,
+ fallback=_FALLBACK,
+ aliases=[],
+ families=[_SANS_SERIF, _JA, _JA],
+ )
+
+ def test_generate_xml(self):
+ xml = generate_xml(
+ fallback=_FALLBACK,
+ aliases=_ALIASES,
+ families=[
+ _SANS_SERIF,
+ _SERIF,
+ _ARABIC,
+ _ARABIC_UI,
+ _HANS,
+ _HANS_SERIF,
+ _JA,
+ _JA_SERIF,
+ _JA_HENTAIGANA,
+ ],
+ )
+
+ self.expect_xml(xml)
+
+ def test_generate_xml_reordered(self):
+ families = [
+ _SANS_SERIF,
+ _SERIF,
+ _ARABIC,
+ _ARABIC_UI,
+ _HANS,
+ _HANS_SERIF,
+ _JA,
+ _JA_SERIF,
+ _JA_HENTAIGANA,
+ ]
+
+ for i in range(0, 10):
+ random.shuffle(families)
+ xml = generate_xml(
+ fallback=_FALLBACK, aliases=_ALIASES, families=families
+ )
+
+ self.expect_xml(xml)
+
+ def expect_xml(self, xml):
+ self.assertEquals("sans-serif", xml.families[0].name) # _SANS_SERIF
+ self.assertEquals("serif", xml.families[1].name) # _SERIF
+ self.assertEquals("und-Arab", xml.families[2].lang) # __ARABIC
+ self.assertEquals("elegant", xml.families[2].variant)
+ self.assertEquals("und-Arab", xml.families[3].lang) # _ARABIC_UI
+ self.assertEquals("zh-Hans", xml.families[4].lang) # _HANS (_HANS_SERIF)
+ self.assertEquals(2, len(xml.families[4].fonts))
+ self.assertEquals("serif", xml.families[4].fonts[1].fallback_for)
+ self.assertEquals("ja", xml.families[5].lang) # _HANS (_HANS_SERIF)
+ self.assertEquals("serif", xml.families[5].fonts[1].fallback_for)
+
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)
diff --git a/data/fonts/script/validators.py b/data/fonts/script/validators.py
new file mode 100755
index 000000000000..9407a59857b6
--- /dev/null
+++ b/data/fonts/script/validators.py
@@ -0,0 +1,99 @@
+#!/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.
+#
+
+"""Validators commonly used."""
+
+
+def check_str_or_none(d, key: str) -> str | None:
+ value = d.get(key)
+ assert value is None or isinstance(value, str), (
+ "%s type must be str or None." % key
+ )
+ return value
+
+
+def check_str(d, key: str) -> str:
+ value = d.get(key)
+ assert isinstance(value, str), "%s type must be str." % key
+ return value
+
+
+def check_int_or_none(d, key: str) -> int | None:
+ """Chcek if the given value of key in dict is int or None."""
+ value = d.get(key)
+ if value is None:
+ return None
+ elif isinstance(value, int):
+ return value
+ elif isinstance(value, str):
+ try:
+ return int(value)
+ except ValueError as e:
+ raise AssertionError() from e
+ else:
+ raise AssertionError("%s type must be int or str or None." % key)
+
+
+def check_float(d, key: str) -> float:
+ """Chcek if the given value of key in dict is float."""
+ value = d.get(key)
+ if isinstance(value, float):
+ return value
+ elif isinstance(value, int):
+ return float(value)
+ elif isinstance(value, str):
+ try:
+ return float(value)
+ except ValueError as e:
+ raise AssertionError() from e
+ else:
+ raise AssertionError("Float value is expeted but it is %s" % key)
+
+
+def check_weight_or_none(d, key: str) -> int | None:
+ value = check_int_or_none(d, key)
+
+ assert value is None or (
+ value >= 0 and value <= 1000
+ ), "weight must be larger than 0 and lower than 1000."
+ return value
+
+
+def check_priority_or_none(d, key: str) -> int | None:
+ value = check_int_or_none(d, key)
+
+ assert value is None or (
+ value >= -100 and value <= 100
+ ), "priority must be between -100 (highest) to 100 (lowest)"
+ return value
+
+
+def check_enum_or_none(d, key: str, enum: [str]) -> str | None:
+ value = check_str_or_none(d, key)
+
+ assert value is None or value in enum, "%s must be None or one of %s" % (
+ key,
+ enum,
+ )
+ return value
+
+
+def check_tag(value) -> str:
+ if len(value) != 4 or not value.isascii():
+ raise AssertionError("OpenType tag must be 4 ASCII letters: %s" % value)
+ return value
diff --git a/data/fonts/script/xml_builder.py b/data/fonts/script/xml_builder.py
new file mode 100755
index 000000000000..38daebce8caf
--- /dev/null
+++ b/data/fonts/script/xml_builder.py
@@ -0,0 +1,275 @@
+#!/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)