mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-10-09 02:54:14 +00:00
272 lines
8.5 KiB
Python
Executable file
272 lines
8.5 KiB
Python
Executable file
#!/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())
|