Compare commits
26 Commits
7a373fae03
...
8413f98f4c
Author | SHA1 | Date | |
---|---|---|---|
|
8413f98f4c | ||
152a07729a | |||
0b17893b45 | |||
87a59a5207 | |||
8c4eb2ddf9 | |||
440ff992da | |||
5bddf37c05 | |||
d6558a77dd | |||
7b07074989 | |||
2b9505b7b3 | |||
43ea03b175 | |||
fd356b5aa8 | |||
5ef7a7fcc0 | |||
6a7cfd0d65 | |||
dfde12daa6 | |||
d3b2bcce3b | |||
751e63e4e7 | |||
4b9ff9e035 | |||
164b71a0c3 | |||
f552becc18 | |||
cde9c3485f | |||
0a3be7ef55 | |||
b215b8ad39 | |||
aba843ef1e | |||
1870471445 | |||
83d0058218 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -3,3 +3,4 @@ exports/*
|
||||
last_path_cache.txt
|
||||
*.fontproj
|
||||
test export/
|
||||
.vscode/
|
@ -3,6 +3,4 @@
|
||||
A bitmap font editor for PolyFont in PolyGun.
|
||||
|
||||
## Dependencies
|
||||
- pygame
|
||||
- numpy
|
||||
- pillow
|
176
canvas.py
Normal file
176
canvas.py
Normal file
@ -0,0 +1,176 @@
|
||||
import base64
|
||||
import platform
|
||||
import tkinter
|
||||
|
||||
GRID_COLOR = "#808080"
|
||||
GRID_GUIDE_COLOR = (128, 128, 255)
|
||||
|
||||
class EditorCanvas(tkinter.Canvas):
|
||||
def __init__(self,project,*args,**kwargs):
|
||||
super().__init__(*args,**kwargs)
|
||||
self.project=project
|
||||
self.grid_size = 32
|
||||
self.current_char=33
|
||||
self.current_char_pixels=[]
|
||||
self.current_char_modified=False
|
||||
self.width=0
|
||||
self.height=0
|
||||
self.prev_mouse_x=0
|
||||
self.prev_mouse_y=0
|
||||
self.view_x=0
|
||||
self.view_y=0
|
||||
self.bind("<B1-Motion>",self.handle_draw)
|
||||
self.bind("<Button-1>",self.handle_draw)
|
||||
self.bind("<Button-2>",self.handle_move_start)
|
||||
self.bind("<B2-Motion>",self.handle_move)
|
||||
self.bind("<Button-3>",self.handle_erase)
|
||||
self.bind("<B3-Motion>",self.handle_erase)
|
||||
if platform.system()=="Windows" or platform.system()=="Darwin":
|
||||
self.bind("<MouseWheel>",lambda event: self.handle_zoom(event.delta//120))
|
||||
else:
|
||||
# This will probably work only on X11
|
||||
self.bind("<Button-4>",lambda _: self.handle_zoom(1))
|
||||
self.bind("<Button-5>",lambda _: self.handle_zoom(-1))
|
||||
|
||||
|
||||
def draw(self):
|
||||
self.delete("all")
|
||||
|
||||
# draw grid
|
||||
for x in range(self.project.char_res[0] + 1):
|
||||
x = x * self.grid_size+self.view_x
|
||||
self.create_line((x,self.view_y),(x,self.view_y+self.height),width=1,fill=GRID_COLOR)
|
||||
for y in range(self.project.char_res[1] + 1):
|
||||
y = y * self.grid_size+self.view_y
|
||||
self.create_line((self.view_x,y),(self.view_x+self.width,y),width=1,fill=GRID_COLOR)
|
||||
|
||||
if self.project.does_char_exist(self.current_char) or self.current_char_modified:
|
||||
for i in range(len(self.current_char_pixels)):
|
||||
x=i//self.project.char_res[1]*self.grid_size+self.view_x
|
||||
y=i%self.project.char_res[1]*self.grid_size+self.view_y
|
||||
if self.current_char_pixels[i]>0:
|
||||
self.create_rectangle((x,y),(x+self.grid_size,y+self.grid_size),fill="white")
|
||||
else:
|
||||
self.create_line((self.view_x,self.view_y),(self.view_x+self.width,self.view_y+self.height),width=3,fill="red")
|
||||
self.create_line((self.view_x+self.width,self.view_y),(self.view_x,self.view_y+self.height),width=3,fill="red")
|
||||
|
||||
|
||||
def handle_draw(self,event):
|
||||
pixel_pos=self.cursor_pos_to_pixel((event.x,event.y))
|
||||
if not pixel_pos:
|
||||
return
|
||||
self.current_char_modified=True
|
||||
self.project.modified=True
|
||||
self.current_char_pixels[pixel_pos[0]*self.project.char_res[1]+pixel_pos[1]]=1
|
||||
self.draw()
|
||||
|
||||
|
||||
def handle_move_start(self,event):
|
||||
self.prev_mouse_x=event.x
|
||||
self.prev_mouse_y=event.y
|
||||
|
||||
|
||||
def handle_move(self,event):
|
||||
self.view_x+=event.x-self.prev_mouse_x
|
||||
self.view_y+=event.y-self.prev_mouse_y
|
||||
self.prev_mouse_x=event.x
|
||||
self.prev_mouse_y=event.y
|
||||
self.draw()
|
||||
|
||||
|
||||
def handle_erase(self,event):
|
||||
pixel_pos=self.cursor_pos_to_pixel((event.x,event.y))
|
||||
if not pixel_pos:
|
||||
return
|
||||
self.current_char_modified=True
|
||||
self.project.modified=True
|
||||
self.current_char_pixels[pixel_pos[0]*self.project.char_res[1]+pixel_pos[1]]=0
|
||||
self.draw()
|
||||
|
||||
|
||||
def handle_zoom(self,delta):
|
||||
self.grid_size+=delta
|
||||
self.width=self.project.char_res[0]*self.grid_size
|
||||
self.height=self.project.char_res[1]*self.grid_size
|
||||
self.draw()
|
||||
|
||||
|
||||
def load_char(self):
|
||||
self.current_char_modified=False
|
||||
if not self.project.does_char_exist(self.current_char):
|
||||
for x in range(len(self.current_char_pixels)):
|
||||
self.current_char_pixels[x]=0
|
||||
self.draw()
|
||||
return
|
||||
pixels=self.project.decode_char(chr(self.current_char))
|
||||
for x in range(len(pixels)):
|
||||
self.current_char_pixels[x]=pixels[x]
|
||||
self.draw()
|
||||
|
||||
|
||||
def save_char(self):
|
||||
if not self.current_char_modified:
|
||||
return
|
||||
|
||||
empty=True
|
||||
for x in range(len(self.current_char_pixels)):
|
||||
if self.current_char_pixels[x]>0:
|
||||
empty=False
|
||||
break
|
||||
if empty:
|
||||
if self.project.does_char_exist(self.current_char):
|
||||
del(self.project.chars[chr(self.current_char)])
|
||||
return
|
||||
|
||||
packed_data=[]
|
||||
bit_counter=0
|
||||
current_value=0
|
||||
for pixel in self.current_char_pixels:
|
||||
if bit_counter==8:
|
||||
packed_data.append(current_value)
|
||||
bit_counter=0
|
||||
current_value=0
|
||||
current_value|=(pixel<<(7-bit_counter))
|
||||
bit_counter+=1
|
||||
if bit_counter>1:
|
||||
packed_data.append(current_value)
|
||||
|
||||
self.project.chars[chr(self.current_char)]=base64.b64encode(bytes(packed_data)).decode("ascii")
|
||||
|
||||
|
||||
def prev_glyph(self):
|
||||
self.save_char()
|
||||
self.current_char-=1
|
||||
self.keep_current_char_in_bounds()
|
||||
self.load_char()
|
||||
self.draw()
|
||||
|
||||
|
||||
def next_glyph(self):
|
||||
self.save_char()
|
||||
self.current_char+=1
|
||||
self.keep_current_char_in_bounds()
|
||||
self.load_char()
|
||||
self.draw()
|
||||
|
||||
|
||||
def cursor_pos_to_pixel(self,pos):
|
||||
pixel_pos = ((pos[0]-self.view_x) // self.grid_size, (pos[1]-self.view_y) // self.grid_size)
|
||||
if pixel_pos[0]<0 or pixel_pos[0]>=self.project.char_res[0] or pixel_pos[1]<0 or pixel_pos[1]>=self.project.char_res[1]:
|
||||
return None
|
||||
return pixel_pos
|
||||
|
||||
|
||||
def keep_current_char_in_bounds(self):
|
||||
if self.current_char<0:
|
||||
self.current_char=0
|
||||
elif self.current_char>99999:
|
||||
self.current_char=99999
|
||||
|
||||
|
||||
def after_project_load(self):
|
||||
self.width=self.project.char_res[0]*self.grid_size
|
||||
self.height=self.project.char_res[1]*self.grid_size
|
||||
self.current_char_pixels=[0 for _ in range(self.project.char_res[0]*self.project.char_res[1])]
|
||||
self.load_char()
|
||||
|
176
cli_ui.py
176
cli_ui.py
@ -1,176 +0,0 @@
|
||||
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 recent project ({os.path.basename(last_path)})")
|
||||
except FileNotFoundError:
|
||||
last_path = None
|
||||
|
||||
|
||||
|
||||
choice = get_choice(
|
||||
"What do you want to do?" + (" No recently 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
69
exporter.py
@ -1,69 +0,0 @@
|
||||
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")
|
467
main.py
467
main.py
@ -1,293 +1,262 @@
|
||||
import os
|
||||
os.environ["PYGAME_HIDE_SUPPORT_PROMPT"] = "1"
|
||||
import pygame
|
||||
import pygame.freetype
|
||||
import numpy as np
|
||||
import cli_ui
|
||||
import tkinter.filedialog
|
||||
import unicodedata
|
||||
import json
|
||||
import base64
|
||||
from exporter import export
|
||||
|
||||
from canvas import *
|
||||
from project import *
|
||||
|
||||
##### CONFIG #####
|
||||
|
||||
# heigh of guide lines from the top of font pixel grid
|
||||
ascender_line = 10
|
||||
descender_line = 20
|
||||
|
||||
|
||||
|
||||
window_size = (
|
||||
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
|
||||
|
||||
|
||||
##################
|
||||
|
||||
project=Project()
|
||||
|
||||
def button_create_project_click(subwindow,w,h):
|
||||
try:
|
||||
width=int(w)
|
||||
except ValueError as e:
|
||||
tkinter.messagebox.showerror("Creating new project", f"Invalid value {w}: {e}")
|
||||
return
|
||||
try:
|
||||
height=int(h)
|
||||
except ValueError as e:
|
||||
tkinter.messagebox.showerror("Creating new project", f"Invalid value {h}: {e}")
|
||||
return
|
||||
project.chars={}
|
||||
project.char_res=(width,height)
|
||||
project.loaded=True
|
||||
canvas_editor.after_project_load()
|
||||
canvas_editor.draw()
|
||||
update_glyph_preview()
|
||||
subwindow.destroy()
|
||||
|
||||
|
||||
proj_data, proj_path = cli_ui.cli_main()
|
||||
chars = proj_data["chars"]
|
||||
def menu_file_new_project_click():
|
||||
global window
|
||||
if project.modified and not tkinter.messagebox.askyesno("Creating new project","You have unsaved changes, are you sure?"):
|
||||
return
|
||||
subwindow=tkinter.Toplevel(window)
|
||||
subwindow.title("New project")
|
||||
|
||||
char_res = (proj_data["char_width"], proj_data["char_height"])
|
||||
label_width=tkinter.Label(subwindow,text="Character width")
|
||||
label_width.pack(side="top")
|
||||
|
||||
entry_width=tkinter.Entry(subwindow,validate="all",validatecommand=((subwindow.register(number_only_validate)),"%P"))
|
||||
entry_width.pack(side="top",fill="x",padx=5)
|
||||
|
||||
label_height=tkinter.Label(subwindow,text="Character height")
|
||||
label_height.pack(side="top")
|
||||
|
||||
entry_height=tkinter.Entry(subwindow,validate="all",validatecommand=((subwindow.register(number_only_validate)),"%P"))
|
||||
entry_height.pack(side="top",fill="x",padx=5)
|
||||
|
||||
button_create=tkinter.Button(subwindow,text="Create",command=lambda: button_create_project_click(subwindow,entry_width.get(),entry_height.get()))
|
||||
button_create.pack(side="bottom",fill="x",padx=5)
|
||||
|
||||
|
||||
|
||||
# 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)
|
||||
def open_project(file_path):
|
||||
try:
|
||||
project.load(file_path)
|
||||
except AttributeError as e:
|
||||
tkinter.messagebox.showerror("Opening project",f"Project '{file_path}' is invalid: {e}")
|
||||
except IOError as e:
|
||||
tkinter.messagebox.showerror("Opening project",f"Failed to open project '{file_path}': {e}")
|
||||
finally:
|
||||
canvas_editor.after_project_load()
|
||||
canvas_editor.draw()
|
||||
update_glyph_preview()
|
||||
|
||||
|
||||
def menu_file_open_project_click():
|
||||
global project
|
||||
global canvas_editor
|
||||
|
||||
|
||||
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
|
||||
file_path = tkinter.filedialog.askopenfilename(
|
||||
title="Open font project",
|
||||
filetypes=[("Font project (json)", "*.fontproj")]
|
||||
)
|
||||
if file_path == "" or file_path==():
|
||||
return
|
||||
|
||||
# save last path to cache
|
||||
with open("last_path_cache.txt", "w") as f:
|
||||
f.write(file_path)
|
||||
|
||||
open_project(file_path)
|
||||
|
||||
|
||||
def menu_file_open_last_project_click():
|
||||
try:
|
||||
cache=open("last_path_cache.txt","r")
|
||||
file_path=cache.read()
|
||||
cache.close()
|
||||
open_project(file_path)
|
||||
except IOError:
|
||||
tkinter.messagebox.showerror("Opening last project","Failed to load last project cache")
|
||||
|
||||
|
||||
def save_project(ask):
|
||||
global canvas_editor
|
||||
global project
|
||||
|
||||
if not project.loaded:
|
||||
return
|
||||
|
||||
path=None
|
||||
if project.path and not ask:
|
||||
path=project.path
|
||||
else:
|
||||
# show file save dialog
|
||||
path=tkinter.filedialog.asksaveasfilename(
|
||||
title="Save font project",
|
||||
filetypes=[("Font project (json)", "*.fontproj")],
|
||||
defaultextension=".fontproj"
|
||||
)
|
||||
if path=="" or path==():
|
||||
return
|
||||
|
||||
# save last path to cache
|
||||
with open("last_path_cache.txt", "w") as f:
|
||||
f.write(path)
|
||||
|
||||
canvas_editor.save_char()
|
||||
try:
|
||||
project.save(path)
|
||||
except IOError as e:
|
||||
tkinter.messagebox.showerror("Saving project",f"Failed to save project '{path}': {e}")
|
||||
|
||||
|
||||
def export_project(ask):
|
||||
if not project.loaded:
|
||||
return
|
||||
|
||||
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]),
|
||||
path=None
|
||||
if project.export_path and not ask:
|
||||
path=project.export_path
|
||||
else:
|
||||
path = tkinter.filedialog.askdirectory(
|
||||
title="Choose folder where exported pages will be saved"
|
||||
)
|
||||
if path=="" or path==():
|
||||
return
|
||||
|
||||
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),
|
||||
)
|
||||
canvas_editor.save_char()
|
||||
try:
|
||||
project.export(path)
|
||||
except IOError as e:
|
||||
tkinter.messagebox.showerror("Exporting project",f"Failed to export project: {e}")
|
||||
|
||||
|
||||
# draw char num
|
||||
font.render_to(
|
||||
window,
|
||||
(canva_width + 30, 30),
|
||||
f"dec: {char_num}",
|
||||
(255, 255, 255),
|
||||
size=64,
|
||||
)
|
||||
def button_prev_glyph_click():
|
||||
global canvas_editor
|
||||
global project
|
||||
if not project.loaded:
|
||||
return
|
||||
canvas_editor.prev_glyph()
|
||||
update_glyph_preview()
|
||||
|
||||
def button_next_glyph_click():
|
||||
global canvas_editor
|
||||
global project
|
||||
if not project.loaded:
|
||||
return
|
||||
canvas_editor.next_glyph()
|
||||
update_glyph_preview()
|
||||
|
||||
|
||||
# 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,
|
||||
)
|
||||
def button_glyph_search_click():
|
||||
global canvas_editor
|
||||
global entry_glyph_id
|
||||
global project
|
||||
if not project.loaded:
|
||||
return
|
||||
code=entry_glyph_id.get()
|
||||
canvas_editor.save_char()
|
||||
try:
|
||||
canvas_editor.current_char=int(code,base=16)
|
||||
except ValueError as e:
|
||||
tkinter.messagebox.showerror("Searching glyph", f"Invalid hex value {code}: {e}")
|
||||
finally:
|
||||
canvas_editor.keep_current_char_in_bounds()
|
||||
canvas_editor.load_char()
|
||||
update_glyph_preview()
|
||||
|
||||
|
||||
# draw char name
|
||||
font.render_to(
|
||||
window,
|
||||
(canva_width + 30, 280),
|
||||
unicodedata.name(chr(char_num), "unknown"),
|
||||
(255, 255, 255),
|
||||
size=16,
|
||||
)
|
||||
def number_only_validate(val):
|
||||
return val.isdigit()
|
||||
|
||||
|
||||
# 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
|
||||
)
|
||||
def hex_only_validate(val):
|
||||
for x in val:
|
||||
if not x.isdigit() and (ord(x)<ord("a") or ord(x)>ord("f")) and (ord(x)<ord("A") or ord(x)>ord("F")):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def update_glyph_preview():
|
||||
global canvas_editor
|
||||
global canvas_preview
|
||||
global label_glyph_name
|
||||
canvas_preview.delete("all")
|
||||
canvas_preview.create_text((50,100),text=chr(canvas_editor.current_char),fill="white",font="tkDefaultFont 70")
|
||||
name=unicodedata.name(chr(canvas_editor.current_char),"unknown")
|
||||
label_glyph_name.config(text=f"{name} U+{canvas_editor.current_char:04x}")
|
||||
|
||||
|
||||
window=tkinter.Tk()
|
||||
window.title("fonteditor")
|
||||
window.geometry(f"{WINDOW_SIZE[0]}x{WINDOW_SIZE[1]}")
|
||||
|
||||
menubar=tkinter.Menu(window)
|
||||
menu_file=tkinter.Menu(menubar,tearoff=False)
|
||||
menu_file.add_command(label="New project",command=menu_file_new_project_click)
|
||||
menu_file.add_command(label="Open project",command=menu_file_open_project_click)
|
||||
menu_file.add_command(label="Open last project",command=menu_file_open_last_project_click)
|
||||
menu_file.add_command(label="Save project",command=lambda: save_project(False))
|
||||
menu_file.add_command(label="Save project as",command=lambda: save_project(True))
|
||||
menu_export=tkinter.Menu(menubar,tearoff=False)
|
||||
menu_export.add_command(label="Export",command=lambda: export_project(False))
|
||||
menu_export.add_command(label="Export as",command=lambda: export_project(True))
|
||||
menubar.add_cascade(label="File",menu=menu_file)
|
||||
menubar.add_cascade(label="Export",menu=menu_export)
|
||||
|
||||
font.render_to(window, (10, 10), f"FPS: {clock.get_fps():.2f}", (255, 255, 255), size=16)
|
||||
canvas_editor=EditorCanvas(project,window,bg="black")
|
||||
canvas_editor.pack(side="left",fill="both",expand=True)
|
||||
|
||||
pygame.display.update()
|
||||
clock.tick(fps_limit)
|
||||
#d_time = clock.tick(fps_limit) / 1000
|
||||
frame_controls=tkinter.Frame(window)
|
||||
frame_controls.pack(side="right")
|
||||
|
||||
canvas_preview=tkinter.Canvas(frame_controls,width=100,height=200,bg="black")
|
||||
canvas_preview.pack(side="top")
|
||||
|
||||
label_glyph_name=tkinter.Label(frame_controls)
|
||||
label_glyph_name.pack(side="top")
|
||||
|
||||
frame_nav=tkinter.Frame(frame_controls)
|
||||
frame_nav.pack(side="top",pady=10)
|
||||
|
||||
button_prev_glyph=tkinter.Button(frame_nav,width=10,text="Previous",command=button_prev_glyph_click)
|
||||
button_prev_glyph.pack(side="left")
|
||||
|
||||
button_next_glyph=tkinter.Button(frame_nav,width=10,text="Next",command=button_next_glyph_click)
|
||||
button_next_glyph.pack(side="left")
|
||||
|
||||
frame_glyph_id=tkinter.Frame(frame_controls)
|
||||
frame_glyph_id.pack(side="top",pady=10)
|
||||
|
||||
entry_glyph_id=tkinter.Entry(frame_glyph_id,validate="all",validatecommand=((window.register(hex_only_validate)),"%P"))
|
||||
entry_glyph_id.pack(side="left")
|
||||
|
||||
button_glyph_search=tkinter.Button(frame_glyph_id,width=10,text="Search",command=button_glyph_search_click)
|
||||
button_glyph_search.pack(side="left")
|
||||
|
||||
window.config(menu=menubar)
|
||||
window.mainloop()
|
||||
|
101
project.py
Normal file
101
project.py
Normal file
@ -0,0 +1,101 @@
|
||||
import base64
|
||||
import json
|
||||
import PIL.Image
|
||||
|
||||
def create_zeroed_array(length):
|
||||
output=[]
|
||||
for _ in range(length):
|
||||
output.append(0)
|
||||
return output
|
||||
|
||||
class Project:
|
||||
def __init__(self):
|
||||
self.chars={}
|
||||
self.char_res=(0,0)
|
||||
self.path=None
|
||||
self.export_path=None
|
||||
self.loaded=False
|
||||
self.modified=False
|
||||
|
||||
|
||||
def load(self,path):
|
||||
with open(path, "r") as f:
|
||||
project_data = json.load(f)
|
||||
if not "char_width" in project_data or not "char_height" in project_data:
|
||||
raise AttributeError("Character metrics information not found")
|
||||
if not isinstance(project_data["char_width"],int) or not isinstance(project_data["char_height"],int):
|
||||
raise AttributeError("Invalid character metrics type, expected int")
|
||||
if not "chars" in project_data:
|
||||
raise AttributeError("Character data not found")
|
||||
if not isinstance(project_data["chars"],dict):
|
||||
raise AttributeError("Invalid character data type, expected object")
|
||||
self.chars=project_data["chars"]
|
||||
self.char_res=(project_data["char_width"],project_data["char_height"])
|
||||
self.path=path
|
||||
self.loaded=True
|
||||
|
||||
|
||||
def save(self,path):
|
||||
# save project data to file
|
||||
with open(path, "w") as f:
|
||||
json.dump({
|
||||
"char_width": self.char_res[0],
|
||||
"char_height": self.char_res[1],
|
||||
"chars": self.chars
|
||||
}, f, indent=4)
|
||||
self.path=path
|
||||
self.loaded=True
|
||||
|
||||
|
||||
def export(self,path):
|
||||
chars = tuple(self.chars.items())
|
||||
|
||||
# there will be 256 characters per page
|
||||
last_char_idx = len(chars) - 1
|
||||
|
||||
page_res = (self.char_res[0] * 16,self.char_res[1] * 16)
|
||||
pages={}
|
||||
|
||||
for char_idx, char in enumerate(chars):
|
||||
char_id=ord(char[0])
|
||||
page=char_id//256
|
||||
char_offset_in_page=char_id-page*256
|
||||
if not page in pages:
|
||||
pages[page] = create_zeroed_array(page_res[0]*page_res[1])
|
||||
|
||||
char_bitmap=self.decode_char(char[0])
|
||||
char_x=char_offset_in_page%16*self.char_res[0]
|
||||
char_y=char_offset_in_page//16*self.char_res[1]
|
||||
|
||||
# put char_bitmap onto page_img at correct position
|
||||
for i in range(len(char_bitmap)):
|
||||
x=i//self.char_res[1]+char_x
|
||||
y=i%self.char_res[1]+char_y
|
||||
pages[page][y*page_res[0]+x]=char_bitmap[i]
|
||||
|
||||
## save page
|
||||
for page,page_data in pages.items():
|
||||
for x in range(len(page_data)):
|
||||
page_data[x]*=255
|
||||
page_img = PIL.Image.frombytes("L",page_res,bytes(page_data))
|
||||
page_img.save(f"{path}/page_{page}.png")
|
||||
|
||||
self.export_path=path
|
||||
|
||||
|
||||
def decode_char(self,char):
|
||||
result=[0 for _ in range(self.char_res[0]*self.char_res[1])]
|
||||
pixels=base64.b64decode(self.chars[char].encode("ascii"))
|
||||
pixel_index=0
|
||||
for pixel in pixels:
|
||||
if pixel_index>=len(result):
|
||||
break
|
||||
for x in range(8):
|
||||
if pixel_index>=len(result):
|
||||
break
|
||||
result[pixel_index]=(pixel>>(7-x))&1
|
||||
pixel_index+=1
|
||||
return result
|
||||
|
||||
def does_char_exist(self,c):
|
||||
return chr(c) in self.chars
|
Loading…
x
Reference in New Issue
Block a user