#!/usr/bin/env python3 # Встроенный emoji-шрифт игры (этап 28). # # Публикуется ТОЛЬКО index.html (см. tools/pack.mjs), поэтому шрифт нельзя # отдавать отдельным файлом — он встраивается в index.html как base64 @font-face # между маркерами EMOJI-FONT:BEGIN/END. # # Исходник: Noto Color Emoji (COLRv1, OFL 1.1) — tools/fonts-src/NotoColorEmoji-Regular.ttf # (COLRv1/2022+ обязателен: в игре есть emoji из Emoji 15.0 — 🪙🪱🪝). # Субсет строится ТОЛЬКО по emoji, реально использованным в index.html # (одна точка истины — сам файл игры; при добавлении новых emoji перегенерировать). # # Использование: # tools/.venv/bin/python tools/emoji-font.py # перегенерировать субсет и вписать в index.html # tools/.venv/bin/python tools/emoji-font.py --check # только проверка: субсет покрывает все emoji index.html # # Если fonttools не установлен: # python3 -m venv tools/.venv && tools/.venv/bin/pip install fonttools brotli import io import os import pathlib import re import sys import unicodedata ROOT = pathlib.Path(__file__).resolve().parent.parent HTML = ROOT / 'index.html' FONT_SRC = pathlib.Path(__file__).resolve().parent / 'fonts-src' / 'NotoColorEmoji-Regular.ttf' BEGIN = '' FONT_FAMILY = 'EmojiEmbedded' # Явные одиночные кодопоинты, которые не попадают в диапазонные правила ниже # (стрелки/знаки, используемые в UI: ↩ и variation selector). EXTRA_CPS = {0x2194, 0x2195, 0x2196, 0x2197, 0x2198, 0x2199, 0x21A9, 0x21AA, 0x21AB, 0x21BA, 0x21BB, 0x21C4, 0x21AC, 0x203C, 0x2049, 0x203D, 0x200D, 0xFE0F} def is_emoji_cp(cp: int) -> bool: c = chr(cp) if unicodedata.category(c) == 'So': return True if 0x1F000 <= cp <= 0x1FAFF: return True if 0x2600 <= cp <= 0x27BF: # Miscellaneous Symbols + Dingbats (⚠⚡⚖⛵⚒✓✨❓…) return True if 0x2300 <= cp <= 0x23FF: # технические (⏸…) return True if 0x2B00 <= cp <= 0x2BFF: # ⭐⬆… return True if 0x25A0 <= cp <= 0x25FF: # геометрические (●○; в NCE их нет — субсет их отбросит) return True return cp in EXTRA_CPS def emoji_codepoints(text: str) -> set: return {ord(c) for c in set(text) if ord(c) > 0x7F and (is_emoji_cp(ord(c)) or ord(c) == 0xFE0F)} def load_font(path): from fontTools.ttLib import TTFont return TTFont(path) # путь или file-объект (BytesIO) def font_cmap(font) -> set: return set(font.getBestCmap().keys()) def subset_font(src_path: str, text: str): """Возвращает (bytes woff2, set кодопоинтов финального cmap).""" from fontTools import subset as ft_subset font = load_font(src_path) opts = ft_subset.Options() opts.flavor = 'woff2' opts.hinting = False opts.legacy_kerning = False opts.notdef_outline = True # legacy-таблицу SVG не нёс: цвет — только через COLRv1 (SVG требует lxml в subsetter) opts.drop_tables = list(opts.drop_tables) + ['SVG'] sub = ft_subset.Subsetter(opts) sub.populate(text=text) sub.subset(font) buf = io.BytesIO() font.save(buf) return buf.getvalue(), font_cmap(font) def ranges_css(cps: set) -> str: """Набор кодопоинтов → компактный unicode-range (диапазоны U+A-B или одиночные U+A).""" xs = sorted(cps) parts, start, prev = [], xs[0], xs[0] for x in xs[1:]: if x == prev + 1: prev = x continue parts.append(f'U+{start:X}' if start == prev else f'U+{start:X}-U+{prev:X}') start = prev = x parts.append(f'U+{start:X}' if start == prev else f'U+{start:X}-U+{prev:X}') return ','.join(parts) def embedded_base64(html: str) -> str: m = re.search(re.escape(BEGIN) + r'.*?base64,([A-Za-z0-9+/=]+).*?' + re.escape(END), html, re.S) if not m: sys.exit(f'check: в index.html не найден блок {BEGIN}…{END} с base64 (сначала сгенерируй)') return m.group(1) def main(): check_only = '--check' in sys.argv[1:] try: import fontTools # noqa: F401 except ImportError: # если есть venv проекта и мы в нём не запущены — перезапускаемся под ним venv_py = pathlib.Path(__file__).resolve().parent / '.venv' / 'bin' / 'python' if venv_py.exists() and os.path.abspath(sys.executable) != str(venv_py.resolve()): os.execv(str(venv_py), [str(venv_py)] + sys.argv) sys.exit('fonttools не найден: python3 -m venv tools/.venv && tools/.venv/bin/pip install fonttools brotli') if not FONT_SRC.exists(): sys.exit(f'нет исходника шрифта: {FONT_SRC} (скачай NotoColorEmoji-Regular.ttf из репо google/fonts, ofl/notocoloremoji/)') html = HTML.read_text(encoding='utf-8') wanted = emoji_codepoints(html) full_cmap = font_cmap(load_font(FONT_SRC)) # Требование к субсету: всё, что есть и в index.html, и в исходнике. # (✓ № ● ○ ↩ и т.п. в NCE отсутствуют — их рисуют системные шрифты, это ок.) required = wanted & full_cmap if check_only: import base64 data = base64.b64decode(embedded_base64(html)) have = font_cmap(load_font(io.BytesIO(data))) missing = sorted(required - have) extra = sorted(have - wanted) if missing: print('CHECK FAIL: в index.html есть emoji, которых нет во встроенном субсете:') for cp in missing: print(f' U+{cp:04X} {chr(cp)}') print('Перегенерируй: tools/.venv/bin/python tools/emoji-font.py') sys.exit(1) print(f'CHECK OK: субсет ({len(data)//1024} КБ в base64-блоке) покрывает все {len(required)} emoji index.html') if extra: print(f' (лишние в субсете, не в index.html: {", ".join(f"U+{cp:04X}" for cp in extra)})') return text = ''.join(chr(cp) for cp in sorted(wanted)) data, have = subset_font(str(FONT_SRC), text) missing = sorted(required - have) if missing: print('Генерация: часть требуемых кодопоинтов не вошла в субсет (нет в исходнике?):') for cp in missing: print(f' U+{cp:04X} {chr(cp)}') sys.exit(1) import base64 b64 = base64.b64encode(data).decode('ascii') block = ( f'{BEGIN} — генерируется tools/emoji-font.py, не редактировать руками -->\n' '\n' f'{END}' ) if re.search(re.escape(BEGIN) + r'.*?' + re.escape(END), html, re.S): html = re.sub(re.escape(BEGIN) + r'.*?' + re.escape(END), lambda _m: block, html, flags=re.S) else: html = html.replace('