Files
fishing-game/tools/emoji-font.py
T
andrey 4c257e19ec Встроенный emoji-шрифт (Этап 28): субсет Noto Color Emoji (COLRv1, OFL) как base64 @font-face 'EmojiEmbedded' в index.html — 57 глифов (все emoji игры), 78 КБ woff2, +105 КБ base64 (155→260 КБ; публикуемый zip 110.9 КБ, лимит 1.22 — 100 МБ). Внешний .woff2 невозможен: pack.mjs публикует строго один index.html. Шрифт ПЕРВЫМ в font-stack html,body и во всех 10 ctx.font (константа UI_FONT, секция «ОТРИСОВКА») — единый Google-стиль emoji на всех платформах; headless-скриншоты больше не зависят от системных emoji-шрифтов. Первый кадр ждёт document.fonts.load('16px "EmojiEmbedded"') с timeout-фолбэком 1.5 с (canvas-emoji 💥🌀⚡💪🐟💤). tools/emoji-font.py — генератор: вытаскивает emoji-кодопоинты из index.html (одна точка истины) → fontTools.subset (woff2, без SVG-таблицы) → вписывает base64 между маркерами EMOJI-FONT:BEGIN/END; --check — регрессия (падает, если новый emoji не в субсете; ✓№●○↩ в NCE нет — ок). venv tools/.venv + исходник tools/fonts-src/ — в gitignore. Осознанно: ⚡⭐☀⚠⏸⚖⛵⚒ теперь цветные на всех платформах (унификация). Тесты §Y7 (164/164, exit 0), check.mjs OK, pack 5/5, pack-smoke 6/6, emoji-font.py --check OK; рендер-пробы canvas 🪙⚠️⏸⭐ — цветные пиксели. screenshots/ — 14 кадров пересняты (среда: системный шрифт Emoji One — на кадрах встроенный Google-стиль). Доки: AGENTS.md (раздел «Встроенный emoji-шрифт», 164 чека), DEVLOG Этап 28.
2026-09-09 13:45:51 +03:00

186 lines
8.3 KiB
Python

#!/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 = '<!-- EMOJI-FONT:BEGIN'
END = '<!-- EMOJI-FONT:END -->'
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'
'<style>\n'
'@font-face{\n'
f"font-family:'{FONT_FAMILY}';\n"
'font-style:normal;\n'
'font-weight:400;\n'
'font-display:block;\n'
f'src:url(data:font/woff2;base64,{b64}) format(\'woff2\');\n'
f'unicode-range:{ranges_css(have & required)};\n'
'}\n'
'</style>\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('<title>Рыбалка</title>', f'<title>Рыбалка</title>\n{block}', 1)
if '<title>Рыбалка</title>' not in html:
sys.exit('не удалось вставить блок: не найден <title>Рыбалка</title> и нет маркеров EMOJI-FONT')
HTML.write_text(html, encoding='utf-8')
print(f'OK: субсет {len(have)} глифов → woff2 {len(data)//1024} КБ → index.html +{(len(b64)//1024)} КБ base64')
print(f' кодопоинты: {", ".join(f"U+{cp:04X}" for cp in sorted(have & required))}')
if __name__ == '__main__':
main()