diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b1f2b9b --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +exports/* +last_path_cache.txt +*.fontproj +test export/ \ No newline at end of file diff --git a/README.md b/README.md index 317d518..d1e21c9 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,8 @@ -# Font-Editor +# FontEditor -Bitmap font editor for PolyFont in PolyGun \ No newline at end of file +A bitmap font editor for PolyFont in PolyGun. + +## Dependencies +- pygame +- numpy +- pillow \ No newline at end of file diff --git a/cli_ui.py b/cli_ui.py new file mode 100644 index 0000000..bb83213 --- /dev/null +++ b/cli_ui.py @@ -0,0 +1,176 @@ +import os +from tkinter import filedialog +import json +import base64 +import numpy as np + + +def get_choice(text, choices): + line = "=" * len(text) + print(line) + print(text) + print(line) + + choices_len = len(choices) + + for i, choice in enumerate(choices): + print(f"{i+1}. {choice}") + + while True: + + try: + choice = int(input(f"> ")) + except ValueError: + print("\nInvalid input! Input must be a number.") + continue + + if choice < 1 or choice > choices_len: + print(f"\nInvalid input! Choose number from the list.") + continue + + return choice - 1 + + +def get_int_input(text, min_val=None, max_val=None): + while True: + try: + value = int(input(text)) + except ValueError: + print("\nInvalid input! Input must be a number.") + continue + + if (min_val is not None and value < min_val) or (max_val is not None and value > max_val): + print(f"\nInvalid input! Input must be between {min_val} and {max_val}.") + continue + + return value + + + +def key_tips(): + print("\n======== CONTROLS ========") + + print("Scroll - Select character") + print("Mouse click/drag - Draw") + print("Delete - Remove character") + print("G - Toggle grid") + print("E - Export font") + print("P - Reprint this controls info") + + print("\nBlank characters are automatically removed.") + #print("Type any character into the console and press enter at any time to jump to it.") + + print("============================\n") + + + +def cli_main(): + while True: + + choices = ["Create new font", "Open font project"] + + # if last_path_cache.txt exists, read it + try: + with open("last_path_cache.txt", "r") as file: + last_path = file.read() + choices.append(f"Open last project ({os.path.basename(last_path)})") + except FileNotFoundError: + last_path = None + + + + choice = get_choice( + "What do you want to do?" + " No last opened project found." if last_path is None else "", + choices + ) + + #choice = 2 + + + # create new font project + if choice == 0: + print("\n" + "=" * 30) + + project_data = { + "char_width": get_int_input("Enter character width: ", min_val=2), + "char_height": get_int_input("Enter character height: ", min_val=2), + "chars": {} + } + + # show file save dialog + file_path = filedialog.asksaveasfilename( + title="Save font project", + filetypes=[("Font project (json)", "*.fontproj")], + defaultextension=".fontproj" + ) + + if file_path == "": + print("\nCanceled.\n") + continue + + # save project data to file + with open(file_path, "w") as f: + json.dump(project_data, f, indent=4) + + # save last path to cache + with open("last_path_cache.txt", "w") as f: + f.write(file_path) + + + # open existing font project + elif choice == 1: + file_path = filedialog.askopenfilename( + title="Open font project", + filetypes=[("Font project (json)", "*.fontproj")] + ) + + if file_path == "": + print("\nCanceled.\n") + continue + + # save last path to cache + with open("last_path_cache.txt", "w") as f: + f.write(file_path) + + with open(file_path, "r") as f: + project_data = json.load(f) + + + # open last project + elif choice == 2: + file_path = last_path + + try: + with open(file_path, "r") as f: + project_data = json.load(f) + except FileNotFoundError: + print("\nCouldn't open last project. File not found.\n") + continue + + + # process project data if opened existing project + if choice != 0: + # reverse the packed characters + unpacked_chars = {} + for key, value in project_data["chars"].items(): + # decode from base64 + decoded_data = base64.b64decode(value.encode("utf-8")) + # unpackbits + unpacked_data = np.unpackbits(np.frombuffer(decoded_data, dtype=np.uint8)) + # remove padding + unpacked_data = unpacked_data[:project_data["char_width"] * project_data["char_height"]] + # reshape into original shape + unpacked_data = unpacked_data.reshape(project_data["char_width"], project_data["char_height"]) + # store unpacked character + unpacked_chars[key] = unpacked_data.astype(bool) + + project_data["chars"] = unpacked_chars + + + + print("\n" + "=" * 30) + print(f"Font resolution: {project_data['char_width']}x{project_data['char_height']}") + + key_tips() + + return project_data, file_path \ No newline at end of file diff --git a/exporter.py b/exporter.py new file mode 100644 index 0000000..a5c9665 --- /dev/null +++ b/exporter.py @@ -0,0 +1,69 @@ +from tkinter import filedialog +from PIL import Image +import numpy as np + + +def export(char_res, chars): + + export_path = filedialog.askdirectory( + title="Choose folder where exported pages will be saved." + ) + + if export_path == "": + print("\nExport canceled.\n") + return + + + char_res = np.array(char_res, dtype=np.uint16) + chars = tuple(chars.items()) + + + # there will be 256 characters per page + + last_char_idx = len(chars) - 1 + + page_res = char_res * 16 + + page = 0 + page_char_x = 0 + page_char_y = 0 + for char_idx, char in enumerate(chars): + if page_char_x == 0 and page_char_y == 0: + page_arr = np.zeros(page_res, dtype=bool) + + char_map_string = "" + + char_string, char_bitmap = char + + + # put char_bitmap onto page_img at correct position + page_arr[ + page_char_x * char_res[0] : (page_char_x + 1) * char_res[0], + page_char_y * char_res[1] : (page_char_y + 1) * char_res[1] + ] = char_bitmap + + char_map_string += char_string + + page_char_x += 1 + + if page_char_x == 16: + page_char_x = 0 + page_char_y += 1 + + if page_char_y == 16 or char_idx == last_char_idx: + ## save page + + # numpy array to 1 bit image + page_img = Image.fromarray(page_arr.T * 255).convert("1") + + # save page + page_img.save(f"{export_path}/page_{page}.png") + + # save char map + with open(f"{export_path}/page_{page}.txt", "w", encoding="utf-32") as f: + f.write(char_map_string) + + page += 1 + page_char_y = 0 + + print("\nExport finished.\n") \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..ba39e16 --- /dev/null +++ b/main.py @@ -0,0 +1,293 @@ +import os +os.environ["PYGAME_HIDE_SUPPORT_PROMPT"] = "1" +import pygame +import pygame.freetype +import numpy as np +import cli_ui +import unicodedata +import json +import base64 +from exporter import export + +##### CONFIG ##### + +# heigh of guide lines from the top of font pixel grid +ascender_line = 10 +descender_line = 20 + + + +window_size = ( + (1280, 720), + (1920, 1019) +)[0] + +# this is just a hard limit additionaly limited by the refresh rate of the monitor (vsync) +fps_limit = 240 + + +grid_color = (128,) * 3 +grid_guide_color = (128, 128, 255) +cross_color = (255, 60, 25) +pixel_color = (255,) * 3 + + +################## + + + +proj_data, proj_path = cli_ui.cli_main() +chars = proj_data["chars"] + +char_res = (proj_data["char_width"], proj_data["char_height"]) + + + +# pygame init +pygame.init() +window = pygame.display.set_mode(window_size, flags=pygame.SCALED, vsync=1) +clock = pygame.time.Clock() +font = pygame.freetype.SysFont("Arial", 32) +alt_font = pygame.freetype.SysFont("monogramextended", 32) + + + + +pixel_size = (window_size[1]-1) / char_res[1] +canva_width = pixel_size * char_res[0] + + + +def cursor_pos_to_pixel(pos): + pixel_pos = (int(pos[0] // pixel_size), int(pos[1] // pixel_size)) + + if pixel_pos[0] < 0 or pixel_pos[1] < 0 or pixel_pos[0] >= char_res[0] or pixel_pos[1] >= char_res[1]: + return None + + return pixel_pos + + + +#d_time = 1 / fps_limit + +is_grid = True + +char_num = 33 + +def check_char_exist(char_num): + return chr(char_num) in chars + +does_char_exist = check_char_exist(char_num) + +force_deleted = False + +# main loop +while True: + for event in pygame.event.get(): + if event.type == pygame.QUIT: + pygame.quit() + quit() + + elif event.type == pygame.MOUSEWHEEL: + char_num += event.y * -1 + + if char_num < 0: + char_num = 0 + elif char_num > 99999: + char_num = 99999 + + does_char_exist = check_char_exist(char_num) + + + elif event.type == pygame.KEYDOWN: + + # delete char + if event.key == pygame.K_DELETE: + if does_char_exist: + del chars[chr(char_num)] + does_char_exist = False + force_deleted = True + + # toggle grid + if event.key == pygame.K_g: + is_grid = not is_grid + + # export font + elif event.key == pygame.K_e: + export(char_res, chars) + + # print key tips + elif event.key == pygame.K_p: + cli_ui.key_tips() + + + # initialize current stroke + elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1: + pixel_pos = cursor_pos_to_pixel(pygame.mouse.get_pos()) + # if char is drawable at current position + if pixel_pos is not None: + # create char dictionary entry if it does not exist + if not does_char_exist: + chars[chr(char_num)] = np.zeros(char_res, dtype=bool) + does_char_exist = True + + paint_color = not chars[chr(char_num)][pixel_pos] + + # save at the end of the stroke + elif (event.type == pygame.MOUSEBUTTONUP and event.button == 1) or force_deleted: + force_deleted = False + + ## packbit all chars + + # calculate the number of bits needed to pad char array to a multiple of 8 + remainder = char_res[0] * char_res[1] % 8 + padding = (8 - remainder) % 8 + + + packed_chars = {} + for key, value in chars.items(): + # reshape into 1D array + new_data = value.reshape(-1) + # pad + new_data = np.pad(new_data, (0, padding), mode="constant") + # reshape the padded array into a 2D array with 8 columns + new_data = new_data.reshape(-1, 8) + # packbits + new_data = np.packbits(new_data, axis=1).tobytes() + # convert to base64 + new_data = base64.b64encode(new_data).decode("utf-8") + + packed_chars[key] = new_data + + proj_data["chars"] = packed_chars + + with open(proj_path, "w", encoding="utf-8") as f: + json.dump(proj_data, f, indent=4, ensure_ascii=False) + + print("Autosaved project.") + + + + + # perform drawing + if pygame.mouse.get_pressed()[0] and does_char_exist: + pixel_pos = cursor_pos_to_pixel(pygame.mouse.get_pos()) + + if pixel_pos is not None: + chars[chr(char_num)][pixel_pos] = paint_color + + # if all pixels are off, delete char + if not np.any(chars[chr(char_num)]): + del chars[chr(char_num)] + does_char_exist = False + + + window.fill((0, 0, 0)) + + + if does_char_exist: + current_char_array = chars[chr(char_num)] + # draw pixels from current_char_array + for y in range(char_res[1]): + for x in range(char_res[0]): + if current_char_array[x, y]: + pygame.draw.rect( + window, + pixel_color, + ( + x * pixel_size, + y * pixel_size, + pixel_size, + pixel_size + ) + ) + + + + if is_grid: + # draw grid + for x in range(char_res[0] + 1): + x = x * pixel_size + pygame.draw.line( + window, + grid_color, + (x, 0), + (x, window_size[1]), + ) + + for y in range(char_res[1] + 1): + real_y = y * pixel_size + pygame.draw.line( + window, + grid_guide_color if (y == ascender_line or y == descender_line) else grid_color, + (0, real_y), + (canva_width, real_y), + ) + + + # draw char num + font.render_to( + window, + (canva_width + 30, 30), + f"dec: {char_num}", + (255, 255, 255), + size=64, + ) + + + # draw char + font.render_to( + window, + (canva_width + 30, 100), + chr(char_num), + (255, 255, 255), + size=150, + ) + + # draw alternate font char + alt_font.render_to( + window, + (canva_width + 200, 100), + chr(char_num), + (255, 255, 255), + size=256, + ) + + + # draw char name + font.render_to( + window, + (canva_width + 30, 280), + unicodedata.name(chr(char_num), "unknown"), + (255, 255, 255), + size=16, + ) + + + # draw a red cross if curren char does not exist + if not does_char_exist: + pygame.draw.line( + window, + cross_color, + (0, 0), + (canva_width, window_size[1]), + 7 + ) + pygame.draw.line( + window, + cross_color, + (0, window_size[1]), + (canva_width, 0), + 7 + ) + + + + + + + font.render_to(window, (10, 10), f"FPS: {clock.get_fps():.2f}", (255, 255, 255), size=16) + + pygame.display.update() + clock.tick(fps_limit) + #d_time = clock.tick(fps_limit) / 1000 \ No newline at end of file