Шрифты в играх: (почти) идеальные засечки, кернинги и иероглифы

Продолжаем серию статей о шрифтах в играх. В первой части мы вспоминали об истории типографики, размышляли о влиянии шрифта на восприятие игры и начали погружаться в формат 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. Каждый следующий шрифт перезатирает символ, который был из предыдущего. То есть самый последний — самый главный. Этот порядок определяется в конфигах проекта.


	
		
		
		
	
	
		
		
		
	

	
		
		
		
	

	
		
			

		
			
		
		
			

			
			
		
	

А так как шрифт для японского получается очень мелким (собираются же только символы для японских текстов), то мы получаем минимальный размер с одной стороны и гибкость настройки — с другой.

Заключение

Мы получили гибкую в настройке систему и забрали боль починки багов с проектов в общую команду технологий. Теперь страдаем копаемся в документации и внутренностях только мы, а коллеги могут делать игры быстрее и лучше, не заботясь о таких деталях.

Полный код двух статей можно посмотреть в нашем публичном репозитории. А пока — не отказывайте себе в удовольствии и пишите в комментариях, что ещё хотите узнать о работе со шрифтами.

 

Источник

Читайте также