first actualy working version

This commit is contained in:
Looki2000 2024-02-29 22:12:14 +01:00
parent 7f9b9b91cd
commit 04bb7ac2fc
5 changed files with 550 additions and 2 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
__pycache__/
exports/*
last_path_cache.txt
*.fontproj
test export/

View File

@ -1,3 +1,8 @@
# Font-Editor
# FontEditor
Bitmap font editor for PolyFont in PolyGun
A bitmap font editor for PolyFont in PolyGun.
## Dependencies
- pygame
- numpy
- pillow

176
cli_ui.py Normal file
View File

@ -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

69
exporter.py Normal file
View File

@ -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")

293
main.py Normal file
View File

@ -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