diff options
| -rw-r--r-- | data/fonts/script/Android.bp | 36 | ||||
| -rwxr-xr-x | data/fonts/script/alias_builder.py | 64 | ||||
| -rwxr-xr-x | data/fonts/script/commandline.py | 71 | ||||
| -rwxr-xr-x | data/fonts/script/custom_json.py | 31 | ||||
| -rwxr-xr-x | data/fonts/script/fallback_builder.py | 61 | ||||
| -rwxr-xr-x | data/fonts/script/family_builder.py | 112 | ||||
| -rwxr-xr-x | data/fonts/script/font_builder.py | 114 | ||||
| -rwxr-xr-x | data/fonts/script/generate_fonts_xml_main.py | 112 | ||||
| -rw-r--r-- | data/fonts/script/test/Android.bp | 29 | ||||
| -rwxr-xr-x | data/fonts/script/test/test_alias_builder.py | 92 | ||||
| -rwxr-xr-x | data/fonts/script/test/test_commandline.py | 100 | ||||
| -rwxr-xr-x | data/fonts/script/test/test_custom_json.py | 48 | ||||
| -rwxr-xr-x | data/fonts/script/test/test_fallback_builder.py | 78 | ||||
| -rwxr-xr-x | data/fonts/script/test/test_family_builder.py | 241 | ||||
| -rwxr-xr-x | data/fonts/script/test/test_font_builder.py | 379 | ||||
| -rwxr-xr-x | data/fonts/script/test/test_main.py | 44 | ||||
| -rwxr-xr-x | data/fonts/script/test/test_xml_builder.py | 344 | ||||
| -rwxr-xr-x | data/fonts/script/validators.py | 99 | ||||
| -rwxr-xr-x | data/fonts/script/xml_builder.py | 275 |
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) |