186 lines
8.3 KiB
Python
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()
|