diff --git a/assets/fonts/NotoColorEmoji.ttf b/assets/fonts/NotoColorEmoji.ttf index 943741df..81b57ad7 100644 Binary files a/assets/fonts/NotoColorEmoji.ttf and b/assets/fonts/NotoColorEmoji.ttf differ diff --git a/scripts/data/NotoColorEmoji.ttf b/scripts/data/NotoColorEmoji.ttf new file mode 100644 index 00000000..943741df Binary files /dev/null and b/scripts/data/NotoColorEmoji.ttf differ diff --git a/scripts/generate_emoji_font.py b/scripts/generate_emoji_font.py new file mode 100755 index 00000000..0a33116e --- /dev/null +++ b/scripts/generate_emoji_font.py @@ -0,0 +1,272 @@ +#!/usr/bin/env python3 +"""Generate the app's subsetted Noto Color Emoji font. + +The full upstream font is kept outside Flutter's asset directory so it is not +packaged accidentally. The subset contains every code point referenced by the +emoji picker, including the skin-tone modifiers that are applied at runtime. +""" + +from __future__ import annotations + +import argparse +import os +from pathlib import Path +import platform +import re +import shutil +import subprocess +import sys +import tempfile + + +SCRIPT_DIR = Path(__file__).resolve().parent +PROJECT_ROOT = SCRIPT_DIR.parent + +DEFAULT_SOURCE_FONT = SCRIPT_DIR / "data" / "NotoColorEmoji.ttf" +DEFAULT_OUTPUT_FONT = PROJECT_ROOT / "assets" / "fonts" / "NotoColorEmoji.ttf" +DEFAULT_EMOJI_SET = ( + PROJECT_ROOT + / "lib" + / "src" + / "visual" + / "components" + / "emoji_picker" + / "default_emoji_set.dart" +) +DEFAULT_SKIN_TONES = ( + PROJECT_ROOT + / "lib" + / "src" + / "visual" + / "components" + / "emoji_picker" + / "skin_tones" + / "emoji_skin_tones.dart" +) + +DART_STRING_CONTENT = r"((?:\\.|[^'\\])*)" +EMOJI_PATTERN = re.compile(r"\bEmoji\s*\(\s*'" + DART_STRING_CONTENT + r"'") +SKIN_TONE_PATTERN = re.compile( + r"\bstatic\s+const\s+String\s+\w+\s*=\s*'" + + DART_STRING_CONTENT + + r"'" +) + + +class GenerationError(RuntimeError): + """Raised when the subset font cannot be generated safely.""" + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--source-font", + type=Path, + default=DEFAULT_SOURCE_FONT, + help=f"full input font (default: {DEFAULT_SOURCE_FONT})", + ) + parser.add_argument( + "--output-font", + type=Path, + default=DEFAULT_OUTPUT_FONT, + help=f"generated Flutter asset (default: {DEFAULT_OUTPUT_FONT})", + ) + parser.add_argument( + "--font-subset", + type=Path, + help="path to Flutter's font-subset executable", + ) + return parser.parse_args() + + +def require_file(path: Path, description: str) -> Path: + path = path.expanduser().resolve() + if not path.is_file(): + raise GenerationError(f"{description} not found: {path}") + return path + + +def decode_dart_string(value: str) -> str: + """Decode the Dart escapes that can occur in an emoji string literal.""" + + value = re.sub( + r"\\u\{([0-9a-fA-F]+)\}", + lambda match: chr(int(match.group(1), 16)), + value, + ) + value = re.sub( + r"\\u([0-9a-fA-F]{4})", + lambda match: chr(int(match.group(1), 16)), + value, + ) + value = value.replace(r"\'", "'").replace(r"\\", "\\") + if "\\" in value: + raise GenerationError(f"unsupported escape in emoji literal: {value!r}") + return value + + +def read_supported_emoji() -> tuple[list[str], set[int]]: + emoji_source = require_file(DEFAULT_EMOJI_SET, "emoji picker data").read_text( + encoding="utf-8" + ) + skin_tone_source = require_file(DEFAULT_SKIN_TONES, "skin-tone data").read_text( + encoding="utf-8" + ) + + emoji = [decode_dart_string(value) for value in EMOJI_PATTERN.findall(emoji_source)] + skin_tones = [ + decode_dart_string(value) + for value in SKIN_TONE_PATTERN.findall(skin_tone_source) + ] + + # A very low result means the Dart format changed and silently generating an + # almost-empty font would be worse than failing the build helper. + if len(emoji) < 100: + raise GenerationError( + f"found only {len(emoji)} emoji entries; the picker parser needs updating" + ) + if len(skin_tones) != 5: + raise GenerationError( + f"expected 5 skin-tone modifiers, found {len(skin_tones)}" + ) + + codepoints = {ord(character) for value in emoji + skin_tones for character in value} + return emoji, codepoints + + +def flutter_root() -> Path: + configured_root = os.environ.get("FLUTTER_ROOT") + if configured_root: + return Path(configured_root).expanduser().resolve() + + flutter = shutil.which("flutter") + if flutter is None: + raise GenerationError( + "Flutter was not found on PATH; set FLUTTER_ROOT or pass --font-subset" + ) + return Path(flutter).resolve().parent.parent + + +def find_font_subsetter(override: Path | None) -> Path: + if override is not None: + subsetter = require_file(override, "font-subset executable") + if not os.access(subsetter, os.X_OK): + raise GenerationError(f"font-subset is not executable: {subsetter}") + return subsetter + + engine_artifacts = flutter_root() / "bin" / "cache" / "artifacts" / "engine" + executable_name = "font-subset.exe" if os.name == "nt" else "font-subset" + candidates = list(engine_artifacts.glob(f"*/{executable_name}")) + executable_candidates = [path for path in candidates if os.access(path, os.X_OK)] + if not executable_candidates: + raise GenerationError( + "Flutter's font-subset executable is missing. Run a Flutter command " + "to populate the engine cache, then try again." + ) + + host_prefix = { + "Darwin": "darwin-", + "Linux": "linux-", + "Windows": "windows-", + }.get(platform.system()) + if host_prefix: + host_candidates = [ + path + for path in executable_candidates + if path.parent.name.startswith(host_prefix) + ] + if host_candidates: + return sorted(host_candidates)[0] + + if len(executable_candidates) == 1: + return executable_candidates[0] + raise GenerationError( + "could not select a host font-subset executable; pass --font-subset explicitly" + ) + + +def generate_font( + source_font: Path, + output_font: Path, + subsetter: Path, + codepoints: set[int], +) -> None: + output_font = output_font.expanduser().resolve() + if source_font == output_font: + raise GenerationError("source and output font paths must be different") + + output_font.parent.mkdir(parents=True, exist_ok=True) + subset_input = " ".join( + f"optional:{codepoint}" for codepoint in sorted(codepoints) + ) + "\n" + + temporary_path: Path | None = None + try: + with tempfile.NamedTemporaryFile( + prefix=f".{output_font.stem}.", + suffix=output_font.suffix, + dir=output_font.parent, + delete=False, + ) as temporary_file: + temporary_path = Path(temporary_file.name) + + result = subprocess.run( + [str(subsetter), str(temporary_path), str(source_font)], + input=subset_input, + text=True, + capture_output=True, + check=False, + ) + if result.returncode != 0: + details = "\n".join( + part.strip() for part in (result.stdout, result.stderr) if part.strip() + ) + raise GenerationError( + f"font-subset failed with exit code {result.returncode}:\n{details}" + ) + + if temporary_path.stat().st_size == 0: + raise GenerationError("font-subset produced an empty file") + if temporary_path.read_bytes()[:4] not in {b"\x00\x01\x00\x00", b"OTTO", b"ttcf"}: + raise GenerationError("font-subset output is not a recognized font") + + temporary_path.chmod(source_font.stat().st_mode & 0o777) + temporary_path.replace(output_font) + temporary_path = None + finally: + if temporary_path is not None: + temporary_path.unlink(missing_ok=True) + + +def format_size(byte_count: int) -> str: + return f"{byte_count / 1_000_000:.2f} MB" + + +def main() -> int: + try: + args = parse_args() + source_font = require_file(args.source_font, "full Noto Color Emoji font") + subsetter = find_font_subsetter(args.font_subset) + emoji, codepoints = read_supported_emoji() + generate_font(source_font, args.output_font, subsetter, codepoints) + + output_font = args.output_font.expanduser().resolve() + source_size = source_font.stat().st_size + output_size = output_font.stat().st_size + saved = source_size - output_size + print( + f"Generated {output_font}\n" + f" Picker entries: {len(emoji)}\n" + f" Unique code points: {len(codepoints)}\n" + f" Full font: {format_size(source_size)}\n" + f" Subset font: {format_size(output_size)}\n" + f" Saved: {format_size(saved)} ({saved / source_size:.1%})" + ) + return 0 + except GenerationError as error: + print(f"error: {error}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main())