From f6fb3be2ca6999e8a7e15de569684e0cb79c60d5 Mon Sep 17 00:00:00 2001 From: Raphael Texier <157415568+rapha-tech@users.noreply.github.com> Date: Sat, 26 Jul 2025 23:22:09 +0200 Subject: [PATCH] Added a python tool to create and edit fonts and added diacritics to french translations (#168) * Update translations.h Adding diacritics File encoding is Latin-1 (ISO 8859-1) * rg_gui: adding missing translations * rg_gui: adding missing translations * moving converter to tool folder Creating basic UI * font_converter : upgrading UI adding settings fields for the user * fonts: adding source fonts I might delete some of them later... * font_converter: updated the tool it's now working properly (sort of) ! * create basic C font renderer I now have to code the part where the user can edit the C file using the canvas like some sort of Windows Paint * font_converter: few tweaks made the code a bit shorter and more readable * font_converter: reverse change that caused an issue * C_font_editor: Now work !!! * font_converter: some tweaks on file output comments * Font tools: solved issue when exporting char with empty data (such as space) * font_converter: Added zoom function + made the tools full screen * font_converter: now can handle 'space' character * font toolss: added a way to exclude some characters and updated file header * font_tool: fixing error with file output * rg_gui: new font renderer for LVGL type font * Removing old font and adding "LVGL" verabold * rg_gui: removing bit per pixel option * rg_gui: renaming a struct to match the rest of the code * font_tools: replaced treshold by bit shifting * fonttool: updated the generator to match the new font format * Font_converter: now generate decent results * fonttools: adding some fonts + somes tweaks * font_editor: works again with the new format ! * font_tools: small tweaks * rg_gui: added back vertical stretching * rg_gui: moved output specific data inside the if(output) * rg_gui: small tweak * font_tools: small tweaks on advance_width * font_editor: new gui * font_editor: improved ergonomics * Renamed edit_c_font to font_editor * Add support for old format to font_converter.py * Missing commas in font_converter.py's output * Fixed memory usage calculation in font_converter * Auto generate data when selecting a font file Automatically generate the canvas when loading a font. Also fixed: Cancelling the file selector will no longer clear the font name * Draw bounding box after the pixels, so that it's fully visible * Do not save empty bitmaps * Added save dialog to font converter * Replaced the blue dot with a box representing the real footprint of the glyph * Converted translations.h to UTF-8 * Made font_editor save in UTF-8 * Removed new format support in font_converter I'm still working on adapting font_editor... * Add option to ensure that font size is respected * font_converter can now load C fonts, and font_editor now imports its load/save abilities * Fixed typo * Fixed max height and memory calculations in font_editor * Blue bounding box should show the actual height taken by the glyph (aka the max height) * Preserve the real padding in the rendered glyph * Reduce ofs_y when possible * Regenerated fonts with font_converter.py I'm still tweaking font_converter.py but the result is pretty close to the previous fonts. In some cases the line height is increased because of diatrics unfortunately... * Restore upstream fonts to resolve conflict * Resolving conflicts * Fixed some glyphs were clipped on the left * Fix conflicts * More logic to reduce max_height * Renamed Original_fonts to just fonts * Removed the mention of the old tool now that we have our own --------- Co-authored-by: Raphael Texier <157415568+raphatex@users.noreply.github.com> Co-authored-by: Alex Duchesne --- components/retro-go/fonts/fonts.h | 7 +- components/retro-go/translations.h | 94 +++--- tools/font_converter.py | 463 +++++++++++++++++++++++++++++ tools/font_editor.py | 391 ++++++++++++++++++++++++ tools/fonts/Apache License.txt | 201 +++++++++++++ tools/fonts/COPYRIGHT.TXT | 124 ++++++++ tools/fonts/DejaVuSans.ttf | Bin 0 -> 741536 bytes tools/fonts/OpenSans-Bold.ttf | Bin 0 -> 224592 bytes tools/fonts/OpenSans-Regular.ttf | Bin 0 -> 217360 bytes tools/fonts/OpenSans-Semibold.ttf | Bin 0 -> 221328 bytes tools/fonts/Vera-Bold.ttf | Bin 0 -> 58716 bytes 11 files changed, 1229 insertions(+), 51 deletions(-) create mode 100644 tools/font_converter.py create mode 100644 tools/font_editor.py create mode 100644 tools/fonts/Apache License.txt create mode 100644 tools/fonts/COPYRIGHT.TXT create mode 100644 tools/fonts/DejaVuSans.ttf create mode 100644 tools/fonts/OpenSans-Bold.ttf create mode 100644 tools/fonts/OpenSans-Regular.ttf create mode 100644 tools/fonts/OpenSans-Semibold.ttf create mode 100644 tools/fonts/Vera-Bold.ttf diff --git a/components/retro-go/fonts/fonts.h b/components/retro-go/fonts/fonts.h index 0f47795b..aa21327f 100644 --- a/components/retro-go/fonts/fonts.h +++ b/components/retro-go/fonts/fonts.h @@ -2,12 +2,7 @@ /** * This file can be edited to add fonts to retro-go. - * There is a tool to convert ttf to prop fonts there: - * https://github.com/loboris/ESP32_TFT_library/tree/master/tools - * - * But you will need to modify its output: - * - The header must be removed (the first 4 bytes of data) - * - All codepoints must be changed to 16bits (insert a 0x00 byte after the first byte of each character) + * To create new fonts you can use font_converter.py located in the tools folder. */ extern const rg_font_t font_basic8x8; diff --git a/components/retro-go/translations.h b/components/retro-go/translations.h index f53c7376..22b67e35 100644 --- a/components/retro-go/translations.h +++ b/components/retro-go/translations.h @@ -48,10 +48,6 @@ static const char *translations[][RG_LANG_MAX] = [RG_LANG_EN] = "Select file", [RG_LANG_FR] = "Choisissez un fichier", }, - { - [RG_LANG_EN] = "Off", - [RG_LANG_FR] = "Off", - }, { [RG_LANG_EN] = "Language", [RG_LANG_FR] = "Langue", @@ -62,11 +58,11 @@ static const char *translations[][RG_LANG_MAX] = }, { [RG_LANG_EN] = "For these changes to take effect you must restart your device.\nrestart now?", - [RG_LANG_FR] = "Pour que ces changements prennent effet, vous devez redemmarer votre appareil.\nRedemmarer maintenant ?", + [RG_LANG_FR] = "Pour que ces changements prennent effet, vous devez redémmarer votre appareil.\nRedémmarer maintenant ?", }, { [RG_LANG_EN] = "Wi-Fi profile", - [RG_LANG_FR] = "Profile Wi-Fi", + [RG_LANG_FR] = "Profil Wi-Fi", }, { [RG_LANG_EN] = "Language", @@ -82,7 +78,7 @@ static const char *translations[][RG_LANG_MAX] = }, { [RG_LANG_EN] = "Reset all settings?", - [RG_LANG_FR] = "Reset tous les parametres", + [RG_LANG_FR] = "Reset tous les paramètres", }, { [RG_LANG_EN] = "Initializing...", @@ -138,7 +134,7 @@ static const char *translations[][RG_LANG_MAX] = }, { [RG_LANG_EN] = "Input", - [RG_LANG_FR] = "Entree", + [RG_LANG_FR] = "Entrée", }, { [RG_LANG_EN] = "Crop", @@ -178,19 +174,19 @@ static const char *translations[][RG_LANG_MAX] = }, { [RG_LANG_EN] = "Download complete!", - [RG_LANG_FR] = "Telechargement termine", + [RG_LANG_FR] = "Téléchargement terminé", }, { [RG_LANG_EN] = "Reboot to flash?", - [RG_LANG_FR] = "Redemarrer", + [RG_LANG_FR] = "Redémarrer", }, { [RG_LANG_EN] = "Available Releases", - [RG_LANG_FR] = "Maj disponibles", + [RG_LANG_FR] = "Maj disponible", }, { [RG_LANG_EN] = "Received empty list!", - [RG_LANG_FR] = "Liste vide recue", + [RG_LANG_FR] = "Liste vide reçue", }, { [RG_LANG_EN] = "Gamma Boost", @@ -226,7 +222,7 @@ static const char *translations[][RG_LANG_MAX] = }, { [RG_LANG_EN] = "RTC config", - [RG_LANG_FR] = "Congig RTC", + [RG_LANG_FR] = "Config RTC", }, { [RG_LANG_EN] = "SRAM autosave", @@ -262,7 +258,7 @@ static const char *translations[][RG_LANG_MAX] = }, { [RG_LANG_EN] = "Crop sides", - [RG_LANG_FR] = "Couper les cotes", + [RG_LANG_FR] = "Couper les côtés", }, { [RG_LANG_EN] = "Sprite limit", @@ -278,11 +274,15 @@ static const char *translations[][RG_LANG_MAX] = }, { [RG_LANG_EN] = "Profile", - [RG_LANG_FR] = "Profile", + [RG_LANG_FR] = "Profil", + }, + { + [RG_LANG_EN] = "", + [RG_LANG_FR] = "", }, { [RG_LANG_EN] = "Controls", - [RG_LANG_FR] = "Controles", + [RG_LANG_FR] = "Contrôles", }, { [RG_LANG_EN] = "Audio enable", @@ -347,23 +347,19 @@ static const char *translations[][RG_LANG_MAX] = }, { [RG_LANG_EN] = "System activity", - [RG_LANG_FR] = "Activite systeme", + [RG_LANG_FR] = "Activité système", }, { [RG_LANG_EN] = "Disk activity", - [RG_LANG_FR] = "Activite stockage", + [RG_LANG_FR] = "Activité stockage", }, { [RG_LANG_EN] = "Low battery", - [RG_LANG_FR] = "Battery basse", + [RG_LANG_FR] = "Batterie basse", }, { [RG_LANG_EN] = "Default", - [RG_LANG_FR] = "Default", - }, - { - [RG_LANG_EN] = "none", - [RG_LANG_FR] = "Aucun", + [RG_LANG_FR] = "Défaut", }, { [RG_LANG_EN] = "", @@ -373,7 +369,7 @@ static const char *translations[][RG_LANG_MAX] = // Wifi { [RG_LANG_EN] = "Not connected", - [RG_LANG_FR] = "Non connecte", + [RG_LANG_FR] = "Non connecté", }, { [RG_LANG_EN] = "Connecting...", @@ -393,7 +389,7 @@ static const char *translations[][RG_LANG_MAX] = }, { [RG_LANG_EN] = "Start access point?\n\nSSID: retro-go\nPassword: retro-go\n\nBrowse: http://192.168.4.1/", - [RG_LANG_FR] = "Demarrer point d'acces?\n\nSSID: retro-go\nPassword: retro-go\n\nAdresse: http://192.168.4.1/", + [RG_LANG_FR] = "Démarrer point d'accès?\n\nSSID: retro-go\nPassword: retro-go\n\nAdresse: http://192.168.4.1/", }, { [RG_LANG_EN] = "Wi-Fi enable", @@ -401,11 +397,11 @@ static const char *translations[][RG_LANG_MAX] = }, { [RG_LANG_EN] = "Wi-Fi access point", - [RG_LANG_FR] = "Point d'acces WiFi", + [RG_LANG_FR] = "Point d'accès WiFi", }, { [RG_LANG_EN] = "Network", - [RG_LANG_FR] = "Reseau", + [RG_LANG_FR] = "Réseau", }, { [RG_LANG_EN] = "IP address", @@ -415,7 +411,7 @@ static const char *translations[][RG_LANG_MAX] = // retro-go settings { [RG_LANG_EN] = "Brightness", - [RG_LANG_FR] = "Luminosite", + [RG_LANG_FR] = "Luminosité", }, { [RG_LANG_EN] = "Volume", @@ -431,7 +427,7 @@ static const char *translations[][RG_LANG_MAX] = }, { [RG_LANG_EN] = "Theme", - [RG_LANG_FR] = "Theme", + [RG_LANG_FR] = "Thème", }, { [RG_LANG_EN] = "Show clock", @@ -479,7 +475,7 @@ static const char *translations[][RG_LANG_MAX] = }, { [RG_LANG_EN] = "Target", - [RG_LANG_FR] = "Cible", + [RG_LANG_FR] = "Appareil", }, { [RG_LANG_EN] = "Website", @@ -499,7 +495,7 @@ static const char *translations[][RG_LANG_MAX] = }, { [RG_LANG_EN] = "Reset settings", - [RG_LANG_FR] = "Reset parametres", + [RG_LANG_FR] = "Reset paramètres", }, // save slot @@ -582,6 +578,10 @@ static const char *translations[][RG_LANG_MAX] = [RG_LANG_EN] = "Hide", [RG_LANG_FR] = "Cacher", }, + { + [RG_LANG_EN] = "Tabs Visibility", + [RG_LANG_FR] = "Visibilitée onglets", + }, // scroll modes { @@ -640,21 +640,25 @@ static const char *translations[][RG_LANG_MAX] = }, // launcher options + { + [RG_LANG_EN] = "Launcher Options", + [RG_LANG_FR] = "Options du lanceur", + }, { [RG_LANG_EN] = "Color theme", [RG_LANG_FR] = "Couleurs", }, { [RG_LANG_EN] = "Preview", - [RG_LANG_FR] = "Apercu", + [RG_LANG_FR] = "Aperçu", }, { [RG_LANG_EN] = "Scroll mode", - [RG_LANG_FR] = "Mode defilement", + [RG_LANG_FR] = "Mode défilement", }, { [RG_LANG_EN] = "Start screen", - [RG_LANG_FR] = "Ecran demarrage", + [RG_LANG_FR] = "Ecran démarrage", }, { [RG_LANG_EN] = "Hide tabs", @@ -666,7 +670,7 @@ static const char *translations[][RG_LANG_MAX] = }, { [RG_LANG_EN] = "Startup app", - [RG_LANG_FR] = "App demarrage", + [RG_LANG_FR] = "App démarrage", }, { [RG_LANG_EN] = "Build CRC cache", @@ -674,7 +678,7 @@ static const char *translations[][RG_LANG_MAX] = }, { [RG_LANG_EN] = "Check for updates", - [RG_LANG_FR] = "Check for updates", + [RG_LANG_FR] = "Verifier mise à jour", }, { [RG_LANG_EN] = "HTTP Server Busy...", @@ -686,7 +690,7 @@ static const char *translations[][RG_LANG_MAX] = }, { [RG_LANG_EN] = "Storage mount failed.\nMake sure the card is FAT32.", - [RG_LANG_FR] = "Erreur montage SD.\nVerifiez que la carte est en FAT32.", + [RG_LANG_FR] = "Erreur carte SD.\nLa carte est bien en FAT32 ?", }, // end of main.c @@ -719,7 +723,7 @@ static const char *translations[][RG_LANG_MAX] = }, { [RG_LANG_EN] = "File not found", - [RG_LANG_FR] = "Fichier non present", + [RG_LANG_FR] = "Fichier non présent", }, // rom options @@ -749,7 +753,7 @@ static const char *translations[][RG_LANG_MAX] = }, { [RG_LANG_EN] = "File properties", - [RG_LANG_FR] = "Propriete", + [RG_LANG_FR] = "Propriétés fichier", }, { [RG_LANG_EN] = "Delete selected file?", @@ -780,7 +784,7 @@ static const char *translations[][RG_LANG_MAX] = }, { [RG_LANG_EN] = "Properties", - [RG_LANG_FR] = "Proprietes", + [RG_LANG_FR] = "Propriétés", }, { [RG_LANG_EN] = "Resume", @@ -804,11 +808,11 @@ static const char *translations[][RG_LANG_MAX] = }, { [RG_LANG_EN] = "Reset all settings", - [RG_LANG_FR] = "Reset parametres", + [RG_LANG_FR] = "Reset paramètres", }, { [RG_LANG_EN] = "Reboot to factory ", - [RG_LANG_FR] = "Redemarrer usine", + [RG_LANG_FR] = "Redémarrer usine", }, { [RG_LANG_EN] = "Reboot to launcher", @@ -816,11 +820,11 @@ static const char *translations[][RG_LANG_MAX] = }, { [RG_LANG_EN] = "Recovery mode", - [RG_LANG_FR] = "Mode de recuperation", + [RG_LANG_FR] = "Mode de recupération", }, { [RG_LANG_EN] = "System Panic!", - [RG_LANG_FR] = "Plantage systeme!", + [RG_LANG_FR] = "Plantage système!", }, { [RG_LANG_EN] = "Save failed", diff --git a/tools/font_converter.py b/tools/font_converter.py new file mode 100644 index 00000000..4ad48f89 --- /dev/null +++ b/tools/font_converter.py @@ -0,0 +1,463 @@ +from PIL import Image, ImageDraw, ImageFont +from tkinter import Tk, Label, Entry, StringVar, Button, Frame, Canvas, filedialog, ttk, Checkbutton, IntVar +import os +import re + +################################ - Font format - ################################ +# +# font: +# | +# ├── glyph_bitmap[] -> 8 bit array containing the bitmap data for all glyph +# | +# └── glyph_data[] -> struct that contains all the data to correctly draw the glyph +# +######################## - Explanation of glyph_bitmap[] - ####################### +# First, let's see an example : '!' +# +# we are going to convert glyph_bitmap[] bytes to binary : +# 11111111, +# 11111111, +# 11000111, +# 11100000, +# +# then we rearrange them : +# [3 bits wide] +# 111 +# 111 +# 111 +# [9 111 We clearly reconize '!' character +# bits 111 +# tall] 111 +# 000 +# 111 +# 111 +# (000000) +# +# Second example with '0' : +# 0x30,0x04,0x07,0x09,0x00,0x07, +# 0x7D,0xFB,0xBF,0x7E,0xFD,0xFB,0xFF,0x7C, +# +# - width = 0x07 = 7 +# - height = 0x09 = 9 +# - data[n] = 0x7D,0xFB,0xBF,0x7E,0xFD,0xFB,0xFF,0x7C +# +# in binary : +# 1111101 +# 11111011 +# 10111111 +# 1111110 +# 11111101 +# 11111011 +# 11111111 +# 1111100 +# +# We see that everything is not aligned so we add zeros ON THE LEFT : +# ->01111101 +# 11111011 +# 10111111 +# ->01111110 +# 11111101 +# 11111011 +# 11111111 +# ->01111100 + +# Next, we rearrange the bits : +# [ 7 bits wide] +# 0111110 +# 1111110 +# 1110111 +# [9 1110111 +# bits 1110111 we can reconize '0' (if you squint a loooot) +# tall] 1110111 +# 1110111 +# 1111111 +# 0111110 +# (0) +# +# And that's basically how characters are encoded using this tool + +# Example usage (defaults parameters) +list_char_ranges_init = "32-126, 160-255" +font_size_init = 11 + +font_path = ("arial.ttf") # Replace with your TTF font path + +# Variables to track panning +start_x = 0 +start_y = 0 + +def get_char_list(): + list_char = [] + for intervals in list_char_ranges.get().split(','): + first = intervals.split('-')[0] + # we check if we the user input is a single char or an interval + try: + second = intervals.split('-')[1] + except IndexError: + list_char.append(int(first)) + else: + second = intervals.split('-')[1] + for char in range(int(first), int(second) + 1): + list_char.append(char) + return list_char + +def find_bounding_box(image): + pixels = image.load() + width, height = image.size + x_min, y_min = width, height + x_max, y_max = 0, 0 + + for y in range(height): + for x in range(width): + if pixels[x, y] >= 1: # Looking for 'on' pixels + x_min = min(x_min, x) + y_min = min(y_min, y) + x_max = max(x_max, x) + y_max = max(y_max, y) + + if x_min > x_max or y_min > y_max: # No target pixels found + return None + return (x_min, y_min, x_max+1, y_max+1) + +def load_ttf_font(font_path, font_size): + # Load the TTF font + enforce_font_size = enforce_font_size_bool.get() + pil_font = ImageFont.truetype(font_path, font_size) + + font_name = ' '.join(pil_font.getname()) + font_data = [] + + for char_code in get_char_list(): + char = chr(char_code) + + image = Image.new("1", (font_size * 2, font_size * 2), 0) # generate mono bmp, 0 = black color + draw = ImageDraw.Draw(image) + # Draw at pos 1 otherwise some glyphs are clipped. we remove the added offset below + draw.text((1, 0), char, font=pil_font, fill=255) + + bbox = find_bounding_box(image) # Get bounding box + + if bbox is None: # control character / space + width, height = 0, 0 + offset_x, offset_y = 0, 0 + else: + x0, y0, x1, y1 = bbox + width, height = x1 - x0, y1 - y0 + offset_x, offset_y = x0, y0 + if offset_x: + offset_x -= 1 + + try: # Get the real glyph width including padding on the right that the box will remove + adv_w = int(draw.textlength(char, font=pil_font)) + adv_w = max(adv_w, width + offset_x) + except: + adv_w = width + offset_x + + # Shift or crop glyphs that would be drawn beyond font_size. Most glyphs are not affected by this. + # If enforce_font_size is false, then max_height will be calculated at the end and the font might + # be taller than requested. + if enforce_font_size and offset_y + height > font_size: + print(f" font_size exceeded: {offset_y+height}") + if font_size - height >= 0: + offset_y = font_size - height + else: + offset_y = 0 + height = font_size + + # Extract bitmap data + cropped_image = image.crop(bbox) + bitmap = [] + row = 0 + i = 0 + for y in range(height): + for x in range(width): + if i == 8: + bitmap.append(row) + row = 0 + i = 0 + pixel = 1 if cropped_image.getpixel((x, y)) else 0 + row = (row << 1) | pixel + i += 1 + bitmap.append(row << 8-i) # to "fill" with zero the remaining empty bits + bitmap = bitmap[0:int((width * height + 7) / 8)] + + # Create glyph entry + glyph_data = { + "char_code": char_code, + "ofs_y": int(offset_y), + "box_w": int(width), + "box_h": int(height), + "ofs_x": int(offset_x), + "adv_w": int(adv_w), + "bitmap": bitmap, + } + font_data.append(glyph_data) + + # The font render glyphs at font_size but they can shift them up or down which will cause the max_height + # to exceed font_size. It's not desirable to remove the padding entirely (the "enforce" option above), + # but there are some things we can do to reduce the discrepency without affecting the look. + max_height = max(g["ofs_y"] + g["box_h"] for g in font_data) + if max_height > font_size: + min_ofs_y = min((g["ofs_y"] if g["box_h"] > 0 else 1000) for g in font_data) + for key, glyph in enumerate(font_data): + offset = glyph["ofs_y"] + # If there's a consistent excess of top padding across all glyphs, we can remove it + if min_ofs_y > 0 and offset >= min_ofs_y: + offset -= min_ofs_y + # In some fonts like Vera and DejaVu we can shift _ and | to gain an extra pixel + if chr(glyph["char_code"]) in ["_", "|"] and offset + glyph["box_h"] > font_size and offset > 0: + offset -= 1 + font_data[key]["ofs_y"] = offset + + max_height = max(g["ofs_y"] + g["box_h"] for g in font_data) + + print(f"Glyphs: {len(font_data)}, font_size: {font_size}, max_height: {max_height}") + + return (font_name, font_size, font_data) + +def load_c_font(file_path): + # Load the C font + font_name = "Unknown" + font_size = 0 + font_data = [] + + with open(file_path, 'r', encoding='UTF-8') as file: + text = file.read() + text = re.sub('//.*?$|/\*.*?\*/', '', text, flags=re.S|re.MULTILINE) + text = re.sub('[\n\r\t\s]+', ' ', text) + # FIXME: Handle parse errors... + if m := re.search('\.name\s*=\s*"(.+)",', text): + font_name = m.group(1) + if m := re.search('\.height\s*=\s*(\d+),', text): + font_size = int(m.group(1)) + if m := re.search('\.data\s*=\s*\{(.+?)\}', text): + hexdata = [int(h, base=16) for h in re.findall('0x[0-9A-Fa-f]{2}', text)] + + while len(hexdata): + char_code = hexdata[0] | (hexdata[1] << 8) + if not char_code: + break + ofs_y = hexdata[2] + box_w = hexdata[3] + box_h = hexdata[4] + ofs_x = hexdata[5] + adv_w = hexdata[6] + bitmap = hexdata[7:int((box_w * box_h + 7) / 8) + 7] + + glyph_data = { + "char_code": char_code, + "ofs_y": ofs_y, + "box_w": box_w, + "box_h": box_h, + "ofs_x": ofs_x, + "adv_w": adv_w, + "bitmap": bitmap, + } + font_data.append(glyph_data) + + hexdata = hexdata[7 + len(bitmap):] + + return (font_name, font_size, font_data) + +def generate_font_data(): + if font_path.endswith(".c"): + font_name, font_size, font_data = load_c_font(font_path) + else: + font_name, font_size, font_data = load_ttf_font(font_path, int(font_height_input.get())) + + window.title(f"Font preview: {font_name} {font_size}") + font_height_input.set(font_size) + + max_height = max(font_size, max(g["ofs_y"] + g["box_h"] for g in font_data)) + bounding_box = bounding_box_bool.get() + + canvas.delete("all") + offset_x_1 = 1 + offset_y_1 = 1 + + for glyph_data in font_data: + offset_y = glyph_data["ofs_y"] + width = glyph_data["box_w"] + height = glyph_data["box_h"] + offset_x = glyph_data["ofs_x"] + adv_w = glyph_data["adv_w"] + + if offset_x_1+adv_w+1 > canva_width: + offset_x_1 = 1 + offset_y_1 += max_height + 1 + + byte_index = 0 + byte_value = 0 + bit_index = 0 + for y in range(height): + for x in range(width): + if bit_index == 0: + byte_value = glyph_data["bitmap"][byte_index] + byte_index += 1 + if byte_value & (1 << 7-bit_index): + canvas.create_rectangle((x+offset_x_1+offset_x)*p_size, (y+offset_y_1+offset_y)*p_size, (x+offset_x_1+offset_x)*p_size+p_size, (y+offset_y_1+offset_y)*p_size+p_size,fill="white") + bit_index += 1 + bit_index %= 8 + + if bounding_box: + canvas.create_rectangle((offset_x_1+offset_x)*p_size, (offset_y_1+offset_y)*p_size, (width+offset_x_1+offset_x)*p_size, (height+offset_y_1+offset_y)*p_size, width=1, outline="red", fill='') # bounding box + canvas.create_rectangle((offset_x_1)*p_size, (offset_y_1)*p_size, (offset_x_1+adv_w)*p_size, (offset_y_1+max_height)*p_size, width=1, outline='blue', fill='') + + offset_x_1 += adv_w + 1 + + return (font_name, font_size, font_data) + +def save_font_data(): + font_name, font_size, font_data = generate_font_data() + + filename = filedialog.asksaveasfilename( + title='Save Font', + initialdir=os.getcwd(), + initialfile=f"{font_name.replace('-', '_').replace(' ', '')}{font_size}", + defaultextension=".c", + filetypes=(('Retro-Go Font', '*.c'), ('All files', '*.*'))) + + if filename: + with open(filename, 'w', encoding='UTF-8') as f: + f.write(generate_c_font(font_name, font_size, font_data)) + +def generate_c_font(font_name, font_size, font_data): + normalized_name = f"{font_name.replace('-', '_').replace(' ', '')}{font_size}" + max_height = max(font_size, max(g["ofs_y"] + g["box_h"] for g in font_data)) + memory_usage = sum(len(g["bitmap"]) + 8 for g in font_data) + + file_data = "#include \"../rg_gui.h\"\n\n" + file_data += "// File generated with font_converter.py (https://github.com/ducalex/retro-go/tree/dev/tools)\n\n" + file_data += f"// Font : {font_name}\n" + file_data += f"// Point Size : {font_size}\n" + file_data += f"// Memory usage : {memory_usage} bytes\n" + file_data += f"// # characters : {len(font_data)}\n\n" + file_data += f"const rg_font_t font_{normalized_name} = {{\n" + file_data += f" .name = \"{font_name}\",\n" + file_data += f" .type = 1,\n" + file_data += f" .width = 0,\n" + file_data += f" .height = {max_height},\n" + file_data += f" .chars = {len(font_data)},\n" + file_data += f" .data = {{\n" + for glyph in font_data: + char_code = glyph['char_code'] + header_data = [char_code & 0xFF, char_code >> 8, glyph['ofs_y'], glyph['box_w'], + glyph['box_h'], glyph['ofs_x'], glyph['adv_w']] + file_data += f" /* U+{char_code:04X} '{chr(char_code)}' */\n " + file_data += ", ".join([f"0x{byte:02X}" for byte in header_data]) + file_data += f",\n " + if len(glyph["bitmap"]) > 0: + file_data += ", ".join([f"0x{byte:02X}" for byte in glyph["bitmap"]]) + file_data += f"," + file_data += "\n" + file_data += "\n" + file_data += " // Terminator\n" + file_data += " 0x00, 0x00,\n" + file_data += " },\n" + file_data += "};\n" + + return file_data + +def select_file(): + filename = filedialog.askopenfilename( + title='Load Font', + initialdir=os.getcwd(), + filetypes=(('True Type Font', '*.ttf'), ('Retro-Go Font', '*.c'), ('All files', '*.*'))) + + if filename: + global font_path + font_path = filename + generate_font_data() + +# Function to zoom in and out on the canvas +def zoom(event): + scale = 1.0 + if event.delta > 0: # Scroll up to zoom in + scale = 1.2 + elif event.delta < 0: # Scroll down to zoom out + scale = 0.8 + + # Get the canvas size and adjust scale based on cursor position + canvas.scale("all", event.x, event.y, scale, scale) + + # Update the scroll region to reflect the new scale + canvas.configure(scrollregion=canvas.bbox("all")) + +def start_pan(event): + global start_x, start_y + # Record the current mouse position + start_x = event.x + start_y = event.y + +def pan_canvas(event): + global start_x, start_y + # Calculate the distance moved + dx = start_x - event.x + dy = start_y - event.y + + # Scroll the canvas + canvas.move("all", -dx, -dy) + + # Update the starting position + start_x = event.x + start_y = event.y + + +if __name__ == "__main__": + window = Tk() + window.title("Retro-Go Font Converter") + + # Get screen width and height + screen_width = window.winfo_screenwidth() + screen_height = window.winfo_screenheight() + # Set the window size to fill the entire screen + window.geometry(f"{screen_width}x{screen_height}") + + p_size = 8 # pixel size on the renderer + + canva_width = screen_width//p_size + canva_height = screen_height//p_size-16 + + frame = Frame(window) + frame.pack(anchor="center", padx=10, pady=2) + + # choose font button (file picker) + choose_font_button = ttk.Button(frame, text='Choose font', command=select_file) + choose_font_button.pack(side="left", padx=5) + + # Label and Entry for Font height + Label(frame, text="Font height").pack(side="left", padx=5) + font_height_input = StringVar(value=str(font_size_init)) + Entry(frame, textvariable=font_height_input, width=4).pack(side="left", padx=5) + + # Variable to hold the state of the checkbox + enforce_font_size_bool = IntVar() # 0 for unchecked, 1 for checked + Checkbutton(frame, text="Enforce size", variable=enforce_font_size_bool).pack(side="left", padx=5) + + # Label and Entry for Char ranges to include + Label(frame, text="Ranges to include").pack(side="left", padx=5) + list_char_ranges = StringVar(value=str(list_char_ranges_init)) + Entry(frame, textvariable=list_char_ranges, width=30).pack(side="left", padx=5) + + # Variable to hold the state of the checkbox + bounding_box_bool = IntVar(value=1) # 0 for unchecked, 1 for checked + Checkbutton(frame, text="Bounding box", variable=bounding_box_bool).pack(side="left", padx=10) + + # Button to launch the font generation function + b1 = Button(frame, text="Preview", width=14, height=2, background="blue", foreground="white", command=generate_font_data) + b1.pack(side="left", padx=5) + + # Button to launch the font exporting function + b1 = Button(frame, text="Save", width=14, height=2, background="blue", foreground="white", command=save_font_data) + b1.pack(side="left", padx=5) + + frame = Frame(window).pack(anchor="w", padx=2, pady=2) + canvas = Canvas(frame, width=canva_width*p_size, height=canva_height*p_size, bg="black") + canvas.configure(scrollregion=(0, 0, canva_width*p_size, canva_height*p_size)) + canvas.bind("", zoom) + canvas.bind("", start_pan) # Start panning + canvas.bind("",pan_canvas) + canvas.focus_set() + canvas.pack(fill="both", expand=True) + + window.mainloop() \ No newline at end of file diff --git a/tools/font_editor.py b/tools/font_editor.py new file mode 100644 index 00000000..7ff26831 --- /dev/null +++ b/tools/font_editor.py @@ -0,0 +1,391 @@ +from tkinter import Tk, Button, Frame, Canvas, filedialog, Checkbutton, IntVar, Label, StringVar, Entry, DISABLED, NORMAL +from font_converter import load_c_font, generate_c_font +import os + +font_size = 14 +char_code_edit = ord('R') +selected_glyph = 0 + +list_bbox = [] # ((cc, x0, y0, x1, y1), (cc, x0, y0, x1, y1), ...) used to find the correct glyph on the canva +list_glyph_data = dict() # contain font data for all glyphs + +lastrect_xy = (0,0) # used for sliding function + +def renderCfont(): + canvas.delete("all") + global select_box + select_box = canvas.create_rectangle(0,0,p_size,p_size, width=2, outline="blue") + + global list_bbox + list_bbox = [] + + offset_x_1 = 1 + offset_y_1 = 1 + + max_height_local = 1 + + global list_glyph_data + + # we get the char list to render + if list_char_render.get() != "": + list_char_code_render = [ord(i) for i in (list_char_render.get())] + else: + list_char_code_render = list(list_glyph_data.keys()) + + for char_code in list_char_code_render: + offset_y = list_glyph_data[char_code]['ofs_y'] + width = list_glyph_data[char_code]['box_w'] + height = list_glyph_data[char_code]['box_h'] + offset_x = list_glyph_data[char_code]['ofs_x'] + xDelta = list_glyph_data[char_code]['adv_w'] + + max_height_local = max(max_height_local, offset_y + height) + + offset_x_1 += offset_x + + if bounding_box_bool.get(): + canvas.create_rectangle((offset_x_1)*p_size, (offset_y_1+offset_y)*p_size, (width+offset_x_1)*p_size, (height+offset_y_1+offset_y)*p_size, width=1, outline="red",fill='') # bounding box + + bbox = ( + char_code, + offset_x_1, + offset_y_1+offset_y, + width+offset_x_1, + height+offset_y_1+offset_y + ) + + byte_list = list_glyph_data[char_code]["bitmap"] + if byte_list: + bitmap_index = 0 + bit_index = 0 + byte = byte_list[bitmap_index] + modulo_8 = (True if width*height%8 == 0 else False) + + for y in range(height): + for x in range(width): + if byte & 0b10000000: # Pixel(x,y) = 1 + canvas.create_rectangle((x+offset_x_1)*p_size, (y+offset_y_1+offset_y)*p_size, (x+offset_x_1)*p_size+p_size, (y+offset_y_1+offset_y)*p_size+p_size,fill="white") + if bit_index == 7: + bit_index = 0 + bitmap_index += 1 + if not (modulo_8 and y == height-1): + byte = byte_list[bitmap_index] + else: + byte = byte << 1 # we shift data[n] to get the next pixel on the most significant bit + bit_index += 1 + + if offset_x_1+3*xDelta <= canva_width: + offset_x_1 += xDelta + else: + offset_x_1 = 1 + offset_y_1 += max_height_local + max_height_local = 1 + + list_bbox.append(bbox) + + +def render_single_char(): + canvas_1.delete("all") + + # we also have to clear the matrix + global rect_ids + rect_ids = [] + for y in range(40): + line = [-1] * 40 + rect_ids.append(line) + + global char_code_edit + char_code_edit = ord(char_to_edit.get()) + global list_glyph_data + + offset_y = list_glyph_data[char_code_edit]['ofs_y'] + width = list_glyph_data[char_code_edit]['box_w'] + height = list_glyph_data[char_code_edit]['box_h'] + offset_x = list_glyph_data[char_code_edit]['ofs_x'] + advance_width = list_glyph_data[char_code_edit]['adv_w'] + + max_size = max(width+offset_x, height+offset_y) + canvas_width = (screen_width // 4) + canvas_height = (screen_height // 2) + + global p_size_c + p_size_c = min(canvas_width, canvas_height) // max_size - 1 + + # ov is the small pixel that stick to the mouse, we want to keep this one + global ov + ov = canvas_1.create_rectangle(0,0,p_size_c,p_size_c,fill="white") + + glyph_data_text = ( + 'width : ' + str(width) + '\n' + + 'height : ' + str(height) + '\n' + + 'offset_x : ' + str(offset_x) + '\n' + + 'offset_y : ' + str(offset_y) + '\n' + + 'advance_width : ' + str(advance_width)) + output.config(text = glyph_data_text) + + global single_bbox + single_bbox = (offset_x, offset_y, offset_x+width, offset_y+height) + + bit_index = 0 + + if bounding_box_bool.get(): + canvas_1.create_rectangle((offset_x)*p_size_c, (offset_y)*p_size_c, (width+offset_x)*p_size_c, (height+offset_y)*p_size_c, width=1, outline="red",fill='') # bounding box + + byte_list = list_glyph_data[char_code_edit]["bitmap"] + bitmap_index = 0 + byte = byte_list[bitmap_index] + modulo_8 = (True if width*height%8 == 0 else False) + for y in range(height): + for x in range(width): + if byte & 0b10000000: # Pixel(x,y) = 1 + rect_ids[y+offset_y][x+offset_x] = canvas_1.create_rectangle( + (x+offset_x)*p_size_c, + (y+offset_y)*p_size_c, + (x+offset_x)*p_size_c+p_size_c, + (y+offset_y)*p_size_c+p_size_c,fill="white") + + if bit_index == 7: + bit_index = 0 + bitmap_index += 1 + if not (modulo_8 and y == height-1): + byte = byte_list[bitmap_index] + + else: + byte = byte << 1 # we shift data[n] to get the next pixel on the most significant bit + bit_index += 1 + + +def update_glyph_data(): + x0, y0, x1, y1 = single_bbox + height = y1 - y0 + width = x1 - x0 + + global char_code_edit + + list_glyph_data[char_code_edit]["bitmap"] = [] + + row = 0 + i = 0 + for y in range(height): + for x in range(width): + pixel = (1 if rect_ids[y + y0][x + x0] != -1 else 0) + if i == 8: + list_glyph_data[char_code_edit]["bitmap"].append(row) + row = 0 + i = 0 + row = (row << 1) | pixel + i += 1 + + row = row << 8-i # to "fill" with zero the remaining empty bits + list_glyph_data[char_code_edit]["bitmap"].append(row) + + save_font() + renderCfont() + + +def save_font(): + global list_glyph_data + global font_path + font_name = os.path.splitext(os.path.basename(font_path))[0] + with open(font_path, 'w', encoding='UTF-8') as f: + f.write(generate_c_font(font_name, font_size, list_glyph_data.values())) + + +def extract_data(): + global list_glyph_data + list_glyph_data.clear() + + font_name, font_size, font_data = load_c_font(font_path) + for glyph in font_data: + list_glyph_data[glyph["char_code"]] = glyph + + b1.config(state=NORMAL) + b3.config(state=NORMAL) + b4.config(state=NORMAL) + renderCfont() + + +def select_file(): + filetypes = ( + ('c font', '*.c'), + ('All files', '*.*') + ) + + filename = filedialog.askopenfilename( + title='Open a c Font', + initialdir=os.getcwd(), + filetypes=filetypes) + + global font_path + font_path = filename + extract_data() + + +def motion(event): + x = event.x // p_size + y = event.y // p_size + + global selected_glyph + + for bbox in list_bbox: + cc, x0, y0, x1, y1 = bbox + if x >= x0 and y >= y0 and x <= x1 and y <= y1: + canvas.coords(select_box, x0*p_size-1, y0*p_size-1, x1*p_size+1, y1*p_size+1) + selected_glyph = cc + + +def click(event): + global char_to_edit + global selected_glyph + char_to_edit.set(chr(selected_glyph)) + render_single_char() + + +def motion_1(event): + global x + global y + x = event.x + y = event.y + if x%p_size_c != 0: + x-= x%p_size_c + if y%p_size_c != 0: + y-= y%p_size_c + canvas_1.itemconfig(ov, fill="White") + canvas_1.coords(ov, x, y, x+p_size_c, y+p_size_c) + + +def click_1(event): + x_pixel = x//p_size_c + y_pixel = y//p_size_c + if rect_ids[y_pixel][x_pixel] == -1: + rect_ids[y_pixel][x_pixel] = canvas_1.create_rectangle(x, y, x + p_size_c, y + p_size_c, fill="white") + else: + canvas_1.delete(rect_ids[y_pixel][x_pixel]) + rect_ids[y_pixel][x_pixel] = -1 + canvas_1.itemconfig(ov, fill="Black") # Changes the fill color to black to "hide" it + + +def slide_1(event): + global x + global y + global lastrect_xy + x = event.x + y = event.y + if x%p_size_c != 0: + x-= x%p_size_c + if y%p_size_c != 0: + y-= y%p_size_c + canvas_1.coords(ov, x, y, x+p_size_c, y+p_size_c) + + x_pixel = x//p_size_c + y_pixel = y//p_size_c + if rect_ids[y_pixel][x_pixel] != lastrect_xy: + if rect_ids[y_pixel][x_pixel] == -1: + rect_ids[y_pixel][x_pixel] = canvas_1.create_rectangle(x, y, x + p_size_c, y + p_size_c, fill="white") + else: + canvas_1.delete(rect_ids[y_pixel][x_pixel]) + rect_ids[y_pixel][x_pixel] = -1 + lastrect_xy = (y_pixel,x_pixel) + + +window = Tk() +window.title("C font editor") + +# TODO : make it dynamic +p_size = 6 # pixel size on the global renderer +p_size_c = 24 # pixel size on the single char renderer + +# Get screen width and height +screen_width = window.winfo_screenwidth() +screen_height = window.winfo_screenheight() +# Set the window size to fill the entire screen +window.geometry(f"{screen_width}x{screen_height}") + +char_edit_windows_width = (screen_width // 4)//p_size_c +char_edit_windows_height = (screen_height // 2)//p_size_c + +canva_width = (screen_width-screen_width // 4)//p_size +canva_height = screen_height//p_size-16 + +frame = Frame(window) +frame.pack(anchor="center", padx=10, pady=2) + +rect_ids = [] # This is gonna be used to store rectangles ids +for y in range(40): + line = [-1] * 40 # Create a new list for each row + rect_ids.append(line) + +########## top ########## +# choose font button +choose_font_button = Button(frame, text='Choose C font', width=16, height=2, background="blue", foreground="white", command=select_file) +choose_font_button.pack(side="left", padx=5) + +# Variable to hold the state of the checkbox +bounding_box_bool = IntVar() # 0 for unchecked, 1 for checked +checkbox = Checkbutton(frame, text="Bounding box", variable=bounding_box_bool) +checkbox.pack(side="left", padx=5) + +# Label and Entry for String to render +Label(frame, text="String to render:").pack(side="left", padx=5) +list_char_render = StringVar(value=str("")) +Entry(frame, textvariable=list_char_render, width=50).pack(side="left", padx=5) + +b1 = Button(frame, text="Render", width=12, height=2, background="blue", foreground="white", command=renderCfont) +b1.pack(side="left", padx=5) +########## end of top ########## + +########## bottom ########## +frame_bottom = Frame(window) +frame_bottom.pack(anchor="s", padx=2, pady=2) + +##### left side ##### +frame_left = Frame(frame_bottom) +frame_left.pack(anchor="center", side="left", padx=2, pady=2) + +# Label and Entry for Chars to render +Label(frame_left, text="Character to edit").pack(side="top", pady=2) +char_to_edit = StringVar(value=str("R")) +Entry(frame_left, textvariable=char_to_edit, width=4).pack(side="top", pady=2) + +b3 = Button(frame_left, text="render", width=12, height=2, background="blue", foreground="white", command=render_single_char) +b3.pack(side="top", padx=5) + +# display the glyph data +Label(frame_left, text="Glyph data :").pack(side="top", pady=2) +output = Label(frame_left, height = 5, width = 25, bg = "light cyan") +output.pack(side="top", padx=5) + +b4 = Button(frame_left, text="save", width=12, height=2, background="green", foreground="white", command=update_glyph_data) +b4.pack(side="top", padx=5) + +# disable buttons until a font is loaded +b1.config(state=DISABLED) +b3.config(state=DISABLED) +b4.config(state=DISABLED) + +canvas_1 = Canvas(frame_left, width=char_edit_windows_width*p_size_c, height=char_edit_windows_height*p_size_c, bg="black") +canvas_1.pack(side="left", padx=5) + +# ov is the small pixel that 'stick' to the mouse +ov = canvas_1.create_rectangle(0,0,p_size_c,p_size_c,fill="white") + +canvas_1.focus_set() +canvas_1.bind('', motion_1) +canvas_1.bind("