Skip to content

Commit

Permalink
Separate butano_fonts_tool from butano repo to gba-free-fonts repo.
Browse files Browse the repository at this point in the history
  • Loading branch information
laqieer committed Feb 12, 2022
1 parent 4ced08b commit a506ba9
Show file tree
Hide file tree
Showing 4 changed files with 337 additions and 1 deletion.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
__pycache__/
build/
*.gba
*.elf
Expand Down
2 changes: 1 addition & 1 deletion examples/butano/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ USERFLAGS :=
USERLIBDIRS :=
USERLIBS :=
USERBUILD :=
EXTTOOL := $(PYTHON) $(LIBBUTANO)/tools/butano_fonts_tool.py --build=$(BUILD) --fonts="$(FONTS)" --texts="$(TEXTS)"
EXTTOOL := $(PYTHON) ../../tools/butano/butano_fonts_tool.py --build=$(BUILD) --fonts="$(FONTS)" --texts="$(TEXTS)"

#---------------------------------------------------------------------------------------------------------------------
# Export absolute butano path:
Expand Down
263 changes: 263 additions & 0 deletions tools/butano/butano_fonts_tool.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
"""
Copyright (c) 2022 laqieer laqieer@126.com
zlib License, see LICENSE file.
"""

# Doc on BMFont: https://angelcode.com/products/bmfont/doc/file_format.html

import os
import sys
import shlex
import codecs
import argparse

from file_info import FileInfo
from PIL import Image

trim_fonts = False
unique_characters = {' ', '!', '"', '#', '$', '%', '&', "'", '(', ')', '*', '+', ',', '-', '.', '/', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', ':', ';', '<', '=', '>', '?', '@', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', '[', '\\', ']', '^', '_', '`', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '{', '|', '}', '~'}


def list_texts_files(texts_paths):
global trim_fonts
texts_file_names = []
texts_file_paths = []

if texts_paths is not None:
texts_path_list = texts_paths.split(' ')

for texts_path in texts_path_list:
if os.path.isfile(texts_path):
texts_file_name = os.path.basename(texts_path)
if FileInfo.validate(texts_file_name):
texts_file_names.append(texts_file_name)
texts_file_paths.append(texts_path)
elif os.path.isdir(texts_path):
folder_texts_file_names = sorted(os.listdir(texts_path))
for texts_file_name in folder_texts_file_names:
texts_file_path = texts_path + '/' + texts_file_name

if os.path.isfile(texts_file_path) and FileInfo.validate(texts_file_name):
texts_file_names.append(texts_file_name)
texts_file_paths.append(texts_file_path)

trim_fonts = len(texts_file_names) > 0

return texts_file_names, texts_file_paths


def process_texts_files(texts_file_paths):
global unique_characters
for texts_file_path in texts_file_paths:
with open(texts_file_path, 'r', encoding='UTF-8') as texts_file:
for line in texts_file:
for char in line:
unique_characters.add(char)


def list_fonts_files(fonts_folder_paths):
fonts_folder_path_list = fonts_folder_paths.split(' ')
fonts_file_names = []
fonts_file_paths = []

for fonts_folder_path in fonts_folder_path_list:
folder_fonts_file_names = sorted(os.listdir(fonts_folder_path))

for fonts_file_name in folder_fonts_file_names:
if fonts_file_name.endswith('.fnt'):
fonts_file_path = fonts_folder_path + '/' + fonts_file_name

if os.path.isfile(fonts_file_path) and FileInfo.validate(fonts_file_name):
fonts_file_names.append(fonts_file_name)
fonts_file_paths.append(fonts_file_path)

return fonts_file_names, fonts_file_paths


def process_fonts_files(fonts_file_paths, build_folder_path):
global trim_fonts, unique_characters
fonts_graphics_path = build_folder_path + '/fonts/'
if not os.path.exists(fonts_graphics_path):
os.makedirs(fonts_graphics_path)
total_number = 0
for fonts_file_path in fonts_file_paths:
fonts_file_path_no_ext = os.path.splitext(fonts_file_path)[0]
fonts_folder_path, fonts_file_name_no_ext = os.path.split(fonts_file_path_no_ext)
font_name = fonts_file_name_no_ext + '_sprite_font'
fonts_header_path = build_folder_path + '/' + font_name + '.h'

with open(fonts_file_path, 'r') as fonts_file, open(fonts_header_path, 'w', encoding='utf-8') as header_file:
font_chars = []
font_widths = [0] * 95
unique_chars = unique_characters.copy()

for fonts_line in fonts_file:
line_type, *pair_tokens = shlex.split(fonts_line)
line_conf = dict(pair_token.split("=", 1) for pair_token in pair_tokens)

if line_type == "info":
padding_up, padding_right, padding_down, padding_left = [int(x) for x in line_conf['padding'].split(',')]
header_file.write('// ' + line_conf['face'].replace('"', '') + ' ' + line_conf['size'] + 'px')
if line_conf['bold'] != '0':
header_file.write(' bold')
if line_conf['italic'] != '0':
header_file.write(' italic')
header_file.write('\n')
header_file.write('\n')
elif line_type == "common":
font_height = int(line_conf['lineHeight'])
if font_height > 64:
raise ValueError('Font is too large')
elif font_height > 32:
font_height = 64
elif font_height > 16:
font_height = 32
elif font_height > 8:
font_height = 16
else:
font_height = 8
font_base = int(line_conf['base'])
font_y_offset = min(font_base - font_height, 0)
# Assume a font's width is not more than its height
font_width = font_height
font_pages = [None] * int(line_conf['pages'])
elif line_type == "page":
page_file_path = fonts_folder_path + '/' + line_conf['file'].replace('"', '')
font_pages[int(line_conf['id'])] = Image.open(page_file_path)
elif line_type == "chars":
font_number = int(line_conf['count'])
#fonts_image = Image.new('RGBA', (font_width, font_height * font_number))
transparent_color = font_pages[0].getpixel((0, 0))
fonts_image = Image.new('RGB', (font_width, font_height * (font_number + 94)), transparent_color)
elif line_type == "char":
if len(unique_characters) == 0:
break
font_code = int(line_conf['id'])
if not trim_fonts or chr(font_code) in unique_chars:
if trim_fonts:
unique_chars.remove(chr(font_code))
src_left = int(line_conf['x']) + padding_left
src_upper = int(line_conf['y']) + padding_up
src_right = int(line_conf['x']) + int(line_conf['width']) - padding_right
if src_right < src_left:
src_right = src_left
src_right = min(src_right, src_left + font_width)
src_lower = int(line_conf['y']) + int(line_conf['height']) - padding_down
if src_lower < src_upper:
src_lower = src_upper
src_upper = max(src_upper, src_lower - font_height)
dst_left = round(float(line_conf['xoffset'])) + padding_left
if dst_left < 0:
dst_left = 0
dst_right = dst_left + src_right - src_left
if dst_right > font_width:
dst_left -= min(dst_left, dst_right - font_width)
dst_upper = round(float(line_conf['yoffset'])) + padding_up
if dst_upper < 0:
dst_upper = 0
dst_lower = dst_upper + src_lower - src_upper
if dst_lower > font_height:
dst_upper -= min(dst_upper, dst_lower - font_height)
if dst_lower > 0:
dst_lower -= min(dst_lower, font_y_offset)
font_w = max(int(line_conf['xadvance']), int(line_conf['width']) - padding_left -padding_right)
font_w = min(font_w, font_width)
if font_code > 126:
font_chars.append(chr(font_code))
font_widths.append(font_w)
dst_upper += font_height * (len(font_widths) - 2)
elif font_code > 31:
font_widths[font_code - 32] = font_w
dst_upper += font_height * (font_code - 33)
if font_code > 32:
fonts_image.paste(font_pages[int(line_conf['page'])].crop((src_left, src_upper, src_right, src_lower)), (dst_left, dst_upper))

header_file.write('#ifndef ' + font_name.upper() + '_H\n')
header_file.write('#define ' + font_name.upper() + '_H\n')
header_file.write('\n')
header_file.write('#include "bn_sprite_font.h"\n')
header_file.write('#include "bn_utf8_characters_map.h"\n')
header_file.write('#include "bn_sprite_items_' + fonts_file_name_no_ext + '.h"\n')
header_file.write('\n')
header_file.write('constexpr bn::utf8_character ' + font_name + '_utf8_characters[] = {\n')
header_file.write(' "' + '", "'.join(font_chars) + '"\n')
header_file.write('};\n')
header_file.write('\n')
header_file.write('constexpr bn::span<const bn::utf8_character> ' + font_name + '_utf8_characters_span(\n')
header_file.write(' ' + font_name + '_utf8_characters);\n')
header_file.write('\n')
header_file.write('constexpr auto ' + font_name + '_utf8_characters_map =\n')
header_file.write(' bn::utf8_characters_map<' + font_name + '_utf8_characters_span>();\n')
header_file.write('\n')
header_file.write('constexpr int8_t ' + font_name + '_character_widths[] = {\n')
header_file.write(' ' + ', '.join([str(x) for x in font_widths]) + '\n')
header_file.write('};\n')
header_file.write('\n')
header_file.write('constexpr bn::sprite_font ' + font_name + '(\n')
header_file.write(' bn::sprite_items::' + fonts_file_name_no_ext + ',\n')
header_file.write(' ' + font_name + '_utf8_characters_map.reference(),\n')
header_file.write(' ' + font_name + '_character_widths);\n')
header_file.write('\n')
header_file.write('#endif')
fonts_image_path_no_ext = fonts_graphics_path + fonts_file_name_no_ext
font_number = len(font_widths) - 1
total_number += font_number
fonts_image_trimmed = Image.new('RGB', (font_width, font_height * total_number), transparent_color)
fonts_image_trimmed.paste(fonts_image)
#fonts_image_trimmed.save(fonts_image_path_no_ext + '.png')
fonts_image_trimmed = fonts_image_trimmed.convert("P", palette=Image.ADAPTIVE, colors=16)
transparent_color_index = fonts_image_trimmed.getpixel((0, 0))
if transparent_color_index > 0:
dest_map = list(range(16))
dest_map[0], dest_map[transparent_color_index] = transparent_color_index, 0
fonts_image_trimmed = fonts_image_trimmed.remap_palette(dest_map)
fonts_image_trimmed.save(fonts_image_path_no_ext + '.bmp')
with open(fonts_image_path_no_ext + '.json', 'w') as json_file:
json_file.write('{\n')
json_file.write(' "type": "sprite",\n')
json_file.write(' "height": ' + str(font_height) + '\n')
json_file.write('}\n')
print(' ' + fonts_file_path + ' font header written in ' + fonts_header_path + ' (character number: ' + str(font_number) + ')')

return total_number


def process_fonts(fonts_folder_paths, build_folder_path, texts_paths):
texts_file_names, texts_file_paths = list_texts_files(texts_paths)
text_file_info_path = build_folder_path + '/_bn_texts_files_info.txt'
old_text_file_info = FileInfo.read(text_file_info_path)
new_text_file_info = FileInfo.build_from_files(texts_file_paths)

fonts_file_names, fonts_file_paths = list_fonts_files(fonts_folder_paths)
font_file_info_path = build_folder_path + '/_bn_fonts_files_info.txt'
old_font_file_info = FileInfo.read(font_file_info_path)
new_font_file_info = FileInfo.build_from_files(fonts_file_paths)

if old_font_file_info == new_font_file_info and old_text_file_info == new_text_file_info:
return

for fonts_file_name in fonts_file_names:
print(fonts_file_name)

sys.stdout.flush()

process_texts_files(texts_file_paths)
total_number = process_fonts_files(fonts_file_paths, build_folder_path)
print(' Processed character number: ' + str(total_number))
new_font_file_info.write(font_file_info_path)
new_text_file_info.write(text_file_info_path)


if __name__ == "__main__":
parser = argparse.ArgumentParser(description='Butano fonts tool.')
parser.add_argument('--build', required=True, help='build folder path')
parser.add_argument('--fonts', required=True, help='fonts folder paths')
parser.add_argument('--texts', required=False, help='texts folder or files paths')

try:
args = parser.parse_args()
process_fonts(args.fonts, args.build, args.texts)
except Exception as ex:
sys.stderr.write('Error: ' + str(ex) + '\n')
traceback.print_exc()
exit(-1)
72 changes: 72 additions & 0 deletions tools/butano/file_info.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"""
Copyright (c) 2020-2022 Gustavo Valiente gustavo.valiente@protonmail.com
zlib License, see LICENSE file.
"""

import os
import string


class FileInfo:

@staticmethod
def validate(file_name):
if file_name[0] == '.':
return False

if file_name[0] not in string.ascii_lowercase:
raise ValueError('Invalid file name: ' + file_name + ' (invalid character: \'' + file_name[0] + '\')')

if len(file_name.split('.')) != 2:
raise ValueError('Invalid file name: ' + file_name + ' (one and only one dot required)')

valid_characters = '_.%s%s' % (string.ascii_lowercase, string.digits)

for file_name_character in file_name:
if file_name_character not in valid_characters:
raise ValueError('Invalid file name: ' + file_name +
' (invalid character: \'' + file_name_character + '\')')

return True

@staticmethod
def read(file_path):
info = ''
read_failed = True

if os.path.isfile(file_path):
with open(file_path, 'r') as file:
info = file.read()
read_failed = False

return FileInfo(info, read_failed)

@staticmethod
def build_from_files(file_paths):
info = []

for file_path in file_paths:
info.append(file_path)
info.append(str(os.path.getmtime(file_path)))

return FileInfo('\n'.join(info), False)

def __init__(self, info, read_failed):
self.__info = info
self.__read_failed = read_failed

def write(self, file_path):
with open(file_path, 'w') as file:
file.write(self.__info)

def __eq__(self, other):
return self.__info == other.__info and self.__read_failed == other.__read_failed

def __ne__(self, other):
return self.__info != other.__info or self.__read_failed != other.__read_failed

def __repr__(self):
if self.__read_failed:
return '[read failed]'

return self.__info

0 comments on commit a506ba9

Please sign in to comment.