Продолжаем серию статей о шрифтах в играх. В первой части мы вспоминали об истории типографики, размышляли о влиянии шрифта на восприятие игры и начали погружаться в формат ttf-файла.
Во второй части мы расскажем, что делать, чтобы буквы не наезжали друг на друга, а все штрихи выглядели правильно. И бонусная сцена для тех, кто останется в зале после титров: хитрости работы со шрифтами для китайских и японских игроков.
FontTools
Светлое будущее наступило с PyPI-библиотекой fontTools, которая устанавливается через pip. Она работает на всех нужных нам платформах (в отличие от FontForge, где нам приходилось танцевать с бубном) и может открывать, создавать, писать таблицы в нужном формате. Но выбирать нужные таблицы, прописывать все значения, правильно копировать глиф — это уже сами. Проделываем те же фокусы, что и с FontForge.
from fontTools.ttLib import TTFont
from fontTools.subset import Subsetter
def erase_not_used(font_path, source_file, symbols):
shutil.copyfile(source_file, font_path)
subsetter = Subsetter()
subsetter.populate(text="".join(symbols))
font = TTFont(font_path)
subsetter.subset(font)
font.save(font_path)
font.close()
if subsetter.unicodes_missing:
# Это символы сердечек, пик и подобных. Они встречаются у нас в тексте, но их нет в исходных шрифтах
service_symbols = [(65024, 65039), (13, 13)]
missed_symbols = [sym for sym in subsetter.unicodes_missing
if not any(beg <= sym <= end for beg, end in service_symbols)]
print(f'There are missing glyphs in source font: {missed_symbols}')
Subsetter умный и сам знает, что для составных глифов необходимо оставлять все его части. Более того, он оставляет данные о составном глифе только в виде информации о точках в таблице glyf
, а в других таблицах вырезает упоминание. Основной плюс подобного вырезания в том, что в шрифте остаются все исходные таблицы со всеми кернингами, инструкциями и хинтами. Слияние многих шрифтов в один такого плюса лишено. Нужно за всем следить самим.
class ParsedFont(object):
def __init__(self, font_path):
self.__file_path = font_path
# Открытый текст - TTFont
self.font = None
# Интересующие нас таблицы
self.hmtx = None
self.vmtx = None
self.glyph_set = None
self.glyf = None
self.cmap = None
self.kern = None
self.os2 = None
# Таблица имён глифов, связь нового имени и имени в шрифте
self.cmap_prepared = None
# Связь имени глифа в шрифте и его unicode
self.name_uni = None
self._is_otf = False
self.__prepare()
def __prepare(self):
self.font = TTFont(self.__file_path)
# Пробел в конце не ошибка, таблица имеет тэг "CFF "
self._is_otf="CFF " in self.font
# Для otf-шрифтов другие таблицы, мы их не сливаем в один
if self._is_otf:
return
self.hmtx = self.font['hmtx']
self.vmtx = self.font['vmtx'] if 'vmtx' in self.font else None
self.vhea = self.font['vhea'] if 'vhea' in self.font else None
self.glyph_set = self.font.getGlyphSet()
self.glyf = self.font['glyf']
self.cmap = self.font['cmap']
self.kern = self.font['kern'] if 'kern' in self.font else None
self.os2 = self.font['OS/2']
self.cmap_prepared = {}
self.name_uni = {}
for table in self.cmap.tables:
for sym, sym_nm in table.cmap.items():
sym_hex = hex(sym)
hex_name = f'{sym_hex}'.replace('0x', '').upper()
# Исправляем имена символов, так как почему-то в некоторых шрифтах решили сделать несовпадающими
# ord(unicode) и glyph_name. Действительно, почему бы и нет
# Но чтобы в текущем шрифте их найти нужно сохранить и первоначальное имя - будет хранится в synonims[0]
true_name = f'u{hex_name}'
syms_codes = self.cmap_prepared.setdefault(sym, {'name': true_name, 'synonims': []})
if sym_nm not in syms_codes:
syms_codes['synonims'].append(sym_nm)
self.name_uni[sym_nm] = sym
@property
def is_otf(self):
return self._is_otf
Во время открытия шрифта подготавливаем таблицу cmap
. В обычном случае давать имена глифам можно любые — это хоть и нужная информация, но стандарт имена никак не регламентирует. Можно хоть букву И назвать в шрифте «кракозябра» — и всё будет работать, на правильное использование и отрисовку шрифта это никак не повлияет. Но для слияния очень важно понимать, какой именно мы обрабатываем глиф, так как таблица у нас сводная. В нашем коде имя заменяется на f’u{Hex}, именно оно будет в подготовленном файле. И также надо сохранить исходное имя, чтобы потом найти информацию о глифе в других таблицах открытого шрифта.
Сбор информации о символе: копирование информации о точках:
from fontTools.pens.ttGlyphPen import TTGlyphPen
def add_symbol(sym_ord: int, glyphs_info: Dict, font: ParsedFont):
glyph_link = font.cmap_prepared.get(sym_ord)
if glyph_link is None:
return None
glyph_name_font = glyph_link['synonims'][0]
glyph = font.glyph_set.get(glyph_name_font)
pen = TTGlyphPen(font.glyf)
glyph.draw(pen)
gl = pen.glyph()
Для копирования точек и кривых в fonttools есть класс TTGlyphPen
: он возьмёт все контуры из глифа и скопирует информацию о компонентах. Но если не добавить в новый шрифт сами компоненты, мы получим в итоге пустой либо недостоверный символ (то же самое поведение, что при удалении лишних символов).
Сбор информации о символе: разбираемся с компонентами
if gl.isComposite():
add_error = True
compound_names = {} # component names
for gl_component in gl.components:
comp_sym_ord = font.name_uni.get(gl_component.glyphName)
if comp_sym_ord is None:
break
sym_name = add_symbol(comp_sym_ord, glyphs_info, font)
if sym_name is None:
break
compound_names[gl_component.glyphName] = sym_name
else:
add_error = False
# В новом шрифте возможно будет другое имя, заменяем его на него
for gl_component in gl.components:
gl_component.glyphName = compound_names[gl_component.glyphName]
if add_error:
fallback_composite_add(gl, font.glyf)
Мы рекурсивно вызываем add_symbol
, чтобы не пропустить глифы, которые включают в себя глифы, которые включают в себя глифы, которые... Ну вы поняли 🙂 Также обязательно заменяем имя глифа в информации о компоненте, так как в шрифт у нас попадает глиф с нормализованным именем.
Ещё один трюк — fallback_composite_add
. Помните, как Subsetter красиво вырезал глифы, не записывая информацию в cmap и другие таблицы? Это как раз обработка такого случая.
from fontTools.ttLib.tables import ttProgram
def fallback_composite_add(gl, glyf):
coords, end_pts, flags = gl.getCoordinates(glyf)
gl.coordinates = coords
gl.endPtsOfContours = end_pts
gl.flags = flags
del gl.components
gl.numberOfContours = len(end_pts)
Метод gl.getCoordinates собирает все точки и контуры, которые используются в глифах-компонентах, и возвращает полный вариант. Другими словами, мы заменяем ссылку на глиф самим глифом.
Следующая часть, которая нас интересует, — инструкции. Эта та штука, которая позволяет попасть глифу в пиксельную сетку красиво. Без них получится что-то такое (погружение в тему).
if hasattr(glyph._glyph, 'program'):
gl.program = deepcopy(glyph._glyph.program)
else:
gl.program = ttProgram.Program()
gl.program.fromBytecode([])
Если вы играли в старые версии Fishdom, то, возможно, замечали, что буквы i, j, t, f частенько друг на друга накладывались.
А это всё потому, что нет времени объяснять, давай релизить! не копировалась информация о кернингах. В этой таблице для некоторых символов сохраняются отступы к другим символам-соседям. Внутреннее устройство таблицы может быть в нескольких форматах, мы поддерживаем только упорядоченные кернинг-пары.
def get_symbol_kerns(glyph_name, kern, name_uni):
if kern is None:
return None
kerns = []
for kern_table in kern.kernTables:
# Поддерживаем только формат 0: упорядоченные пары
if kern_table.format != 0:
continue
pairs = []
for kern_pair in kern_table.kernTable:
if kern_pair[0] == glyph_name:
pairs.append((name_uni.get(kern_pair[1]), kern_table[kern_pair]))
if pairs:
kerns.append({'coverage': kern_table.coverage, 'pairs': pairs})
return kerns
Осталось только всё это записать в нашу сводную таблицу glyphs_info
. Дополнительно указав метрики горизонтальных интервалов (hmtx).
new_name = glyph_link['name']
glyphs_info[sym_ord] = {
'name': glyph_link['name'],
'hmtx': font.hmtx[glyph_name_font],
'kern': get_symbol_kerns(glyph_name_font, font.kern, font.name_uni),
'glyph': gl
}
Теперь настала очередь готовить наш шрифт! Сначала собираем информацию в glyphs_info
.
def merge_fonts(font_path: str, source_files: List[str], symbols, metrics_source, font_name):
glyphs_info = {}
found_symbols = []
for source_path in source_files:
font_add = ParsedFont(source_path)
if font_add.is_otf:
print(f'[ERROR] Merge of otf fonts does not supported.')
return
for symbol in symbols:
sym_ord = ord(symbol)
if add_symbol(sym_ord, glyphs_info, font_add):
found_symbols.append(symbol)
После сбора необходимо обратно развернуть в таблицы всё собранное уже для конечного файла шрифта.
glyphs = []
character_map = {}
h_metrics = {}
glyphs_setup = {}
kerns = {}
ord_name_links = {}
for sym_id, gl_info in glyphs_info.items():
gl_name = gl_info['name']
ord_name_links[gl_name] = sym_id
# Таблицы cmap, glyf и htmx
character_map[sym_id] = gl_name
h_metrics[gl_name] = gl_info['hmtx']
glyphs.append(gl_name)
glyphs_setup[gl_name] = gl_info['glyph']
# Генерирования информация для kern-таблицы, которая отдельно записывается
kern_info = gl_info['kern']
if kern_info is not None:
for kern_coverage in kern_info:
coverage = kern_coverage['coverage']
kern_table = kerns.setdefault(coverage, {})
for pair in kern_coverage['pairs']:
# возможно, что символ для кернинга не нужен в результирующем шрифте
if pair[0] not in glyphs_info:
continue
pair_gl_name = glyphs_info[pair[0]]['name']
kern_table.update({(gl_name, pair_gl_name): pair[1]})
Так как мы сливаем не все глифы, то при сохранении кернингов ещё проверим, что глиф из связки в принципе попадает в итоговый шрифт. Если нет — не сохраняем эту кернинг-пару.
fb = FontBuilder(unitsPerEm=1024, isTTF=True)
fb.setupGlyphOrder(glyphs)
fb.setupCharacterMap(character_map)
fb.setupGlyf(glyphs_setup)
fb.setupHorizontalMetrics(h_metrics)
metrics_font = ParsedFont(metrics_source) if metrics_source is not None else None
metrics = get_metrics(metrics_font)
fb.setupHorizontalHeader(**metrics.hhea)
font_name = font_name.replace(' ', '')
setup_name_table(metrics_font=metrics_font, font_builder=fb, font_name=font_name)
fb.setupOS2(**metrics.os2)
setup_kern_table(fb.font, kerns)
setup_hints(metrics_font=metrics_font, font=fb.font, logger=logger, font_name=font_name)
fb.setupPost()
fb.save(font_path)
Часть работы за нас делает FontBuilder из пакета fonttools, принимая питоновские dict. Но некоторые вещи нужно учитывать в своём коде.
Кернинги. Нужно перенести наши записи в таблицу. В нашем случае было логичным писать в том же формате, в котором мы читаем, не углубляясь в дебри документации.
from fontTools.ttLib.tables._k_e_r_n import KernTable_format_0
def setup_kern_table(font, kerns):
ttf_kern_table = newTable('kern')
ttf_kern_table.version = 0
ttf_kern_table.kernTables = []
for coverage, kern_table in kerns.items():
ttf_kern_value = KernTable_format_0(apple=False)
ttf_kern_value.coverage = coverage
ttf_kern_value.format = 0
ttf_kern_value.kernTable = kern_table
ttf_kern_value.tupleIndex = None
ttf_kern_value.version = 0
ttf_kern_table.kernTables.append(ttf_kern_value)
font['kern'] = ttf_kern_table
Таблицы для хинтов. Это набор из таблиц fpgm
, prep
, cvt
, maxp
, которые определяют общие инструкции для всего шрифта. Нужно это для того, чтобы символы не слипались в комок при уменьшении (вот тут прям подробно). Помните первую картинку с приветственного экрана Gardenscapes?
Мы как раз теряли хинтинг. Возвращаем на место — setup_hints
. Для этого нам нужен шрифт, откуда брать инструкции — metrics_font
. Если его нет, тогда пропускаем шаг, так как автоматом генерировать такое — слишком даже для нас.
def setup_hints(metrics_font, font, font_name):
if metrics_font is None:
return
fpgm = metrics_font.font['fpgm'] if 'fpgm' in metrics_font.font else None
prep = metrics_font.font['prep'] if 'prep' in metrics_font.font else None
cvt = metrics_font.font['cvt '] if 'cvt ' in metrics_font.font else None
maxp_src_table = metrics_font.font['maxp'] if 'maxp' in metrics_font.font else None
# Строгое условие, всё хорошо - нам просто не надо ничего копировать
if fpgm is None and prep is None and cvt is None:
return
if fpgm is None or prep is None or cvt is None:
print(f'[WARNING] Expected prep+cvt+fpgm tables in source font: fpgm="{fpgm is not None}"; '
f'cvt="{cvt is not None}"; prep="{prep is not None}"')
return
fpgm_table = newTable('fpgm')
fpgm_table.program = fpgm.program
font['fpgm'] = fpgm_table
cvt_table = newTable('cvt ')
cvt_table.values = cvt.values
font['cvt '] = cvt_table
prep_table = newTable('prep')
prep_table.program = prep.program
font['prep'] = prep_table
maxp_table = font['maxp']
maxp_table.maxZones = maxp_src_table.maxZones
maxp_table.maxStorage = maxp_src_table.maxStorage
maxp_table.maxFunctionDefs = maxp_src_table.maxFunctionDefs
maxp_table.maxTwilightPoints = maxp_src_table.maxTwilightPoints
maxp_table.maxStackElements = maxp_src_table.maxStackElements
maxp_table.maxInstructionDefs = maxp_src_table.maxInstructionDefs
if maxp_table.maxFunctionDefs == 0:
print(f'[WARNING] [{font_name}] There are instructions in font but functionDefs in "maxp" table == 0')
if maxp_table.maxStackElements == 0:
print(f'[WARNING] [{font_name}] There are instructions in font but maxStackElements in "maxp" table == 0')
Заметки для Windows
Если шрифт используется на Windows, то для правильной работы обязательно нужна запись os/2, а имя шрифта в соответствующем поле должно быть без пробелов.
def setup_name_table(metrics_font, font_builder, font_name):
style_name = "TauStyle"
if metrics_font is not None and 'name' in metrics_font.font:
# Берём имя стиля из таблицы шрифта для метрик, если он есть
style_name_record = metrics_font.font['name'].getName(nameID=2, platformID=3, platEncID=1, langID=1033) \
or metrics_font.font['name'].getName(nameID=2, platformID=3, platEncID=1, langID=None)
if style_name_record is not None:
style_name = style_name_record.toUnicode()
name_strings = dict(familyName=dict(en=font_name),
styleName=dict(en=style_name),
uniqueFontIdentifier=f'tau_empire:{font_name}',
fullName=f'{font_name}',
psName=f'{font_name}',
version='Version 0.1')
font_builder.setupNameTable(name_strings)
Высший пилотаж для китайских и японских игроков
Если в вашем приложении есть китайская и японская локализация, то высшим пилотажем будет подготовить для них отдельные шрифты.
Китайские и японские иероглифы сильно отличаются, хоть и имеют одно происхождение — японские символы изначально были заимствованы из китайского языка. С тех пор возникли заметные различия в написании, а с появлением компьютеров каждая нация разработала собственные шрифты, которые передают нюансы в начертании.
А если использовать один и тот же шрифт, то велика вероятность, что пользователи почувствуют то же, что чувствуем мы, когда видим такие вывески:
У нас в играх используется система приоритета символов в загружаемых шрифтах. Она работает с двух сторон.
Движок при загрузке может переопределять символы на лету и для японской локализации добавлять шрифт, собранный специально для японской локали.
Система сборки: имеет встроенный приоритет исходных шрифтов, который мы уже реализовали в функции merge_fonts
. Каждый следующий шрифт перезатирает символ, который был из предыдущего. То есть самый последний — самый главный. Этот порядок определяется в конфигах проекта.
А так как шрифт для японского получается очень мелким (собираются же только символы для японских текстов), то мы получаем минимальный размер с одной стороны и гибкость настройки — с другой.
Заключение
Мы получили гибкую в настройке систему и забрали боль починки багов с проектов в общую команду технологий. Теперь страдаем копаемся в документации и внутренностях только мы, а коллеги могут делать игры быстрее и лучше, не заботясь о таких деталях.
Полный код двух статей можно посмотреть в нашем публичном репозитории. А пока — не отказывайте себе в удовольствии и пишите в комментариях, что ещё хотите узнать о работе со шрифтами.