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

от автора

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

Высший пилотаж для китайских и японских игроков

Если в вашем приложении есть китайская и японская локализация, то высшим пилотажем будет подготовить для них отдельные шрифты.

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

Слева направо: китайский упрощённый, китайский традиционный и японский.
Слева направо: китайский упрощённый, китайский традиционный и японский.

А если использовать один и тот же шрифт, то велика вероятность, что пользователи почувствуют то же, что чувствуем мы, когда видим такие вывески:

У нас в играх используется система приоритета символов в загружаемых шрифтах. Она работает с двух сторон.

Движок при загрузке может переопределять символы на лету и для японской локализации добавлять шрифт, собранный специально для японской локали.

<!-- Для всех локализаций --> <Locale> <Font name="HanSansNormal" /> </Locale> <!-- Для японской отдельно загрузим сверху шрифт. А уже встречающиеся символы перекроем --> <Locale lang="ja"> <Font name="HanSansNormal"> <SubFont name="HanSansNormal_diff_ja" extenstionMode="Override" /> </Font> </Locale>

Система сборки: имеет встроенный приоритет исходных шрифтов, который мы уже реализовали в функции merge_fonts. Каждый следующий шрифт перезатирает символ, который был из предыдущего. То есть самый последний — самый главный. Этот порядок определяется в конфигах проекта.

<font id="HanSansNormal"> <SourceFonts id="common" type="common"> <source path="PoetsenOne.ttf" /> <source path="Ubuntu.ttf" /> <source path="FDCustomHSNormal.ttf" /> </SourceFonts> <SourceFonts id="general"> <source path="SourceHanSans-Normal.ttf" /> <source path="SourceHanSansSC-Normal.ttf" /> <source path="SourceHanSansK-Normal.ttf" /> </SourceFonts>  <SourceFonts id="ja"> <source path="SourceHanSansSC-Normal.ttf" /> <source path="SourceHanSansK-Normal.ttf" /> <source path="SourceHanSans-Normal.ttf" /> </SourceFonts>  <Generate> <font path="font/HanSansNormal.ttf"> <symbols id="general" />  <source id="common" /> <source id="general" /> </font> <font path="font/HanSansNormal_diff_ja.ttf"> <symbols id="ja" />  <source id="common" /> <source id="ja" /> </font> </Generate> </font>

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

Заключение

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

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


ссылка на оригинал статьи https://habr.com/ru/company/playrix/blog/652031/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *