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