Compare commits

...

5 Commits

7 changed files with 1010 additions and 323 deletions

View File

@ -0,0 +1,109 @@
from datetime import datetime, timedelta, timezone
from types import TracebackType
from typing import BinaryIO, Callable, List, Optional, Tuple, Type, TypeVar
from io_utils import Parser, read_fixed_point, read_u16, read_u64
def read_fixed(f: BinaryIO) -> float: # 16.16
return read_fixed_point(f, 16, 16)
def read_fixed_version(f: BinaryIO) -> float: # Not the same as parse_fixed
majorVersion = read_u16(f)
minorVersion = read_u16(f)
assert minorVersion in [0x0000, 0x1000, 0x5000], f"Invalid fixed minorVersion: {hex(minorVersion)}"
return majorVersion + minorVersion/0xa000 # will need to change if there are ever any versions with 2 decimal digits
def read_F2DOT14(f: BinaryIO) -> float: # F2DOT14 (2.14)
return read_fixed_point(f, 2, 14)
EPOCH = datetime(1904, 1, 1, tzinfo=timezone.utc)
def read_long_datetime(f: BinaryIO) -> datetime:
return EPOCH+timedelta(seconds=read_u64(f))
T = TypeVar('T')
"""
The following `parse_at_...` functions all modify the tell of f, so it is recommended that you use `SaveTell` to wrap calling these.
"""
def parse_at_offset(f: BinaryIO, start_tell: int, offset: int, parser: Parser[T], *, zero_is_null:bool=True) -> T:
"""
Parses a `T` using `parser` at an offset of `offset` from `start_tell`.
If `zero_is_null` is True, then `offset` cannot be 0.
Only set `zero_is_null` to False when you are sure that 0 is a valid offset
"""
if zero_is_null: assert offset != 0, f"Offset was NULL"
f.seek(start_tell+offset)
return parser(f)
def parse_at_optional_offset(f: BinaryIO, start_tell: int, offset: int, parser: Parser[T]) -> Optional[T]:
"""
Same as `parse_at_offset`, however if the offset is NULL (0), then None is returned.
Should not be used when 0 is a valid offset to something.
"""
if offset == 0: return None
return parse_at_offset(f, start_tell, offset, parser, zero_is_null=True)
def parse_at_offsets(f: BinaryIO, start_tell: int, offsets: List[int], parser: Parser[T], *, zero_is_null:bool=True) -> List[T]:
return [parse_at_offset(f, start_tell, offset, parser, zero_is_null=zero_is_null) for offset in offsets]
def parse_at_optional_offsets(f: BinaryIO, start_tell: int, offsets: List[int], parser: Parser[T]) -> List[Optional[T]]:
return [parse_at_optional_offset(f, start_tell, offset, parser) for offset in offsets]
def parse_at_offsets_using_length(f: BinaryIO, start_tell: int, offsets: List[int], parser: Callable[[BinaryIO, int, int], T], *, zero_is_null:bool=True) -> List[Optional[T]]:
"""
The length of the returned list will be one less than that of `offsets`, as the last offset is used to calculate the length of the final element.
`parser` is of the form `(f: BinaryIO, index: int, length: int) -> T`
"""
elements: List[Optional[T]] = []
for i, offset in enumerate(offsets[:-1]):
length = offsets[i+1]-offset
if length == 0:
elements.append(None)
continue
elements.append(parse_at_offset(f, start_tell, offset, lambda f: parser(f, i, length), zero_is_null=zero_is_null))
return elements
def parse_list_at_offset(f: BinaryIO, start_tell: int, offset: int, count: int, parser: Parser[T], *, zero_is_null:bool=True) -> List[T]:
"""
Parses a continuous list of `T`s with `count` elements.
"""
return parse_at_offset(f, start_tell, offset, lambda f: [parser(f) for _ in range(count)], zero_is_null=zero_is_null)
def parse_list_and_use_offsets_into(f: BinaryIO, start_tell: int, offsets: list[int], count: int, parser: Parser[T]) -> Tuple[List[T], List[T]]:
elements = [(f.tell(), parser(f)) for _ in range(count)]
elements_by_offset_into: List[T] = []
for offset in offsets:
for (offset_into, element) in elements:
if offset + start_tell == offset_into:
elements_by_offset_into.append(element)
break
else:
assert False, (f"No element with offset {offset} into this list of elements", start_tell, offsets, elements)
return elements_by_offset_into, [element for (_, element) in elements]
class SaveTell:
"""
A context manager that allows operations to be done on a BinaryIO without affecting the tell.
"""
def __init__(self, f: BinaryIO):
self.f = f
def __enter__(self) -> int:
self.tell = self.f.tell()
return self.tell
def __exit__(self, exc_type: Optional[Type[BaseException]], exc_value: Optional[BaseException], exc_tb: Optional[TracebackType]):
self.f.seek(self.tell)
def null_if_zero(n: int) -> Optional[int]: return n if n else None
def nulls_if_zero(ns: List[int]) -> List[Optional[int]]: return list(map(null_if_zero, ns))

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,6 @@
class ABD:
from abc import ABC, abstractmethod
class ABD(ABC):
"""
#### Abstract Base Dataclass
@ -9,11 +11,14 @@ class ABD:
msg = f"Cannot instantiate an Abstract Base Dataclass: {self.__class__.__name__}"
raise TypeError(msg)
# TODO: Make a subclass of EnumMeta to do this
class ABE:
"""
#### Abstract Base Enum
This is for classes that will have an Enum subclass them
This is for classes that will have an Enum subclass them.
Do not implement a __init__ method for the class directly inheriting from ABE
"""
def __init__(self, *args, **kwargs):
if ABE in self.__class__.__bases__ or ABE == self:

View File

@ -1,9 +1,23 @@
from typing import BinaryIO
from io import SEEK_END
from typing import BinaryIO, Callable, Literal, TypeVar
ENDIANNESS = 'big'
def len_to_end(f: BinaryIO) -> int:
curr_tell = f.tell()
f.seek(0, SEEK_END)
end_tell = f.tell()
f.seek(curr_tell)
return end_tell - curr_tell
def read_int(f: BinaryIO, number: int, signed:bool=False) -> int: return int.from_bytes(f.read(number), ENDIANNESS, signed=signed)
def write_int(f: BinaryIO, value: int, number: int, signed:bool=False) -> int: return f.write(value.to_bytes(number, ENDIANNESS, signed=signed))
def is_at_end(f: BinaryIO) -> bool:
return len_to_end(f) == 0
ENDIANNESS: Literal['little', 'big'] = 'big'
def read_int_from_bytes(s: bytes, *, signed:bool=False) -> int: return int.from_bytes(s, ENDIANNESS, signed=signed)
def bytes_from_int(value: int, number: int, *, signed:bool=False) -> bytes: return value.to_bytes(number, ENDIANNESS, signed=signed)
def read_int(f: BinaryIO, number: int, *, signed:bool=False) -> int: return read_int_from_bytes(f.read(number), signed=signed)
def write_int(f: BinaryIO, value: int, number: int, signed:bool=False) -> int: return f.write(bytes_from_int(value, number, signed=signed))
def read_u64(f: BinaryIO) -> int: return read_int(f, 8)
def read_u32(f: BinaryIO) -> int: return read_int(f, 4)
@ -11,7 +25,7 @@ def read_u24(f: BinaryIO) -> int: return read_int(f, 3)
def read_u16(f: BinaryIO) -> int: return read_int(f, 2)
def read_u8(f: BinaryIO) -> int: return read_int(f, 1)
def write_u16(f: BinaryIO, value: int) -> int: return f.write(value)
def write_u16(f: BinaryIO, value: int) -> int: return write_int(f, value, 2)
def read_i32(f: BinaryIO) -> int: return read_int(f, 4, signed=True)
def read_i16(f: BinaryIO) -> int: return read_int(f, 2, signed=True)
@ -23,9 +37,12 @@ def read_ascii(f: BinaryIO, number: int) -> str: return f.read(number).decode(en
def read_fixed_point(f: BinaryIO, preradix_bits: int, postradix_bits:int, *, signed:bool=True) -> float:
assert (preradix_bits+postradix_bits)%8 == 0
raw = read_int(f, (preradix_bits+postradix_bits)//8, signed=signed)
return raw/(1<<(postradix_bits))
return raw/(1<<postradix_bits)
def read_pascal_string(f: BinaryIO) -> str:
string_size = read_int(f, 1)
string_size = read_u8(f)
pascal_string = read_ascii(f, string_size)
return pascal_string
return pascal_string
T = TypeVar('T')
Parser = Callable[[BinaryIO], T]

136
OpenFontFormat/renderer.py Normal file
View File

@ -0,0 +1,136 @@
import sys
from typing import Any, Generator, List, Optional, Tuple
from pyray import GRAY, GREEN, RED, WHITE, YELLOW, Color as Colour, ConfigFlags, KeyboardKey, MouseButton, Rectangle, TextureFilter, Vector2, begin_drawing, check_collision_point_rec, clear_background, close_window, draw_circle, draw_circle_3d, draw_circle_v, draw_line, draw_line_ex, draw_rectangle_lines_ex, draw_rectangle_rec, draw_spline_bezier_quadratic, draw_text, draw_texture_pro, end_drawing, gen_texture_mipmaps, get_char_pressed, get_color as get_colour, get_mouse_position, get_mouse_wheel_move, get_screen_height, get_screen_width, get_time, init_window, is_mouse_button_pressed, is_mouse_button_released, load_image, load_texture_from_image, set_config_flags, set_exit_key, set_target_fps, set_texture_filter, vector2_zero, window_should_close # type: ignore
from OpenFont import CompoundGlyph, Glyph, SimpleGlyph, SimpleGlyphFlag, TrueTypeOutlines, get_full_name, get_glyph_index, glyph_count, open_font_file
from slot import Slot
import builtins
_program, file_path = sys.argv
file = open_font_file(file_path)
font_x_min, font_y_min, font_x_max, font_y_max = file.font_header.xMin, file.font_header.yMin, file.font_header.xMax, file.font_header.yMax
SCALE = 0.5
font_x_range = font_x_max-font_x_min
font_y_range = font_y_max-font_y_min
SCREEN_WIDTH = int(font_x_range*SCALE)
SCREEN_HEIGHT = int(font_y_range*SCALE)
COLOUR_BACKGROUND = get_colour(0x181818FF)
def middle_point(point1: Tuple[int, int], point2: Tuple[int, int]) -> Tuple[int, int]:
(x0, y0), (x1, y1) = point1, point2
return (x0+x1)//2, (y0+y1)//2
def triple_iter(coordinates: List[Tuple[int, int]], flags: List[SimpleGlyphFlag]) -> Generator[Tuple[Tuple[int,int], Tuple[int,int], Tuple[int,int]], None, None]:
assert flags[0].on_curve_point()
coordinates_and_flags = list(zip(coordinates, flags))
all_points: List[Tuple[int, int]] = []
for (point1, flag1), (point2, flag2) in zip(coordinates_and_flags,coordinates_and_flags[1:]+[coordinates_and_flags[0]]):
all_points.append(point1)
if flag1.on_curve_point() == flag2.on_curve_point(): all_points.append(middle_point(point1, point2))
all_points.append(point2)
assert len(all_points) % 2 != 0
for i in range(1,len(all_points),2):
yield all_points[i-1], all_points[i], all_points[i+1]
def draw_bounding_box(slot: Slot, glyph: Glyph, colour: Colour=RED):
bounding_rect = Rectangle(glyph.xMin, glyph.yMin, glyph.xMax-glyph.xMin, glyph.yMax-glyph.yMin)
draw_rectangle_lines_ex(slot.r(bounding_rect), 1, colour)
def render_simple_glyph(slot: Slot, glyph: SimpleGlyph):
"""
Renders a simple glyph into a slot in coordinate space
"""
draw_bounding_box(slot, glyph)
for prev_end_point, end_point in zip([-1]+glyph.endPtsOfContours, glyph.endPtsOfContours):
for point1, point2, point3 in triple_iter(glyph.coordinates[prev_end_point+1:end_point+1], glyph.flags[prev_end_point+1:end_point+1]):
draw_spline_bezier_quadratic([slot.vxy(*point1), slot.vxy(*point2), slot.vxy(*point3)], 3, 2, WHITE)
def render_box_and_get_slot(slot: Slot) -> Tuple[Slot, Slot]:
"""
Renders the outer box and axes lines into a slot in UV space and returns the outer box slot and the slot in coordinate space
"""
with slot.padding(0.025, 0.025) as slot:
draw_rectangle_lines_ex(slot.r(), 1, GRAY)
outer_slot = slot
with slot.padding(0.05, 0.05).with_ratio(font_x_range/font_y_range).remap(font_x_min, font_y_max, font_x_max, font_y_min) as slot:
draw_line_ex(slot.vxy(font_x_min, 0), slot.vxy(font_x_max, 0), 1, WHITE)
draw_line_ex(slot.vxy(0, font_y_min), slot.vxy(0, font_y_max), 1, WHITE)
return outer_slot, slot
def render_glyph(slot: Slot, glyphID: int):
# TODO: Keep the current display mode as a 'wireframe' mode and allow user to switch between normal and wireframe modes
"""
Renders a glyph into a slot in coordinates space
"""
assert isinstance(file.outlines, TrueTypeOutlines)
glyph = file.outlines.glyph_data.glyphs[glyphID]
match glyph:
case SimpleGlyph(): render_simple_glyph(slot, glyph)
case CompoundGlyph(components=components):
draw_bounding_box(slot, glyph)
for component in components:
# TODO: Scaling
# arg1 and arg2 will also need to be scaled, depending on the values of scaled_component_offset and unscaled_component_offset
assert component.scaling is None, f"Unimplemented: {component.scaling}"
assert component.flag.args_are_xy_values(), "Unimplemented"
# assert not component.flag.round_xy_to_grid(), ("Unimplemented", component.argument1, component.argument2)
render_glyph(slot.translate(component.argument1, component.argument2), component.glyphIndex)
case None: pass # TODO: Render bounding box of empty glyphs
set_config_flags(ConfigFlags.FLAG_WINDOW_ALWAYS_RUN | ConfigFlags.FLAG_WINDOW_RESIZABLE)
init_window(SCREEN_WIDTH, SCREEN_HEIGHT, get_full_name(file))
set_target_fps(60)
set_exit_key(KeyboardKey.KEY_NULL)
COLS = 8
ROWS = 8
scroll_y: float = 0
MAX_SCROLL_IN_SCREEN_HEIGHTS = (glyph_count(file)+COLS-1)//COLS/ROWS-1
SCROLL_SPEED = 10
def rectangle_is_visible(rect: Rectangle) -> bool:
return -rect.width <= rect.x <= get_screen_width() and -rect.height <= rect.y <= get_screen_height()
def clamp_scroll(scroll_y: float) -> float:
return max(min(scroll_y,MAX_SCROLL_IN_SCREEN_HEIGHTS),0)
display_in_detail: Optional[int] = None
while not window_should_close():
scroll_y -= get_mouse_wheel_move()*SCROLL_SPEED/get_screen_height()
scroll_y = clamp_scroll(scroll_y)
begin_drawing()
clear_background(COLOUR_BACKGROUND)
with Slot.get_root_slot() as root:
curr_display_detail = display_in_detail
with root.translate(0, -scroll_y) as slot:
for i, slot in enumerate(slot.extending_grid(ROWS, COLS, -1, total_slots=glyph_count(file))):
if not rectangle_is_visible(slot.r()): continue
outer_slot, glyph_slot = render_box_and_get_slot(slot)
render_glyph(glyph_slot, i)
if display_in_detail is None and is_mouse_button_released(MouseButton.MOUSE_BUTTON_LEFT):
if check_collision_point_rec(get_mouse_position(), outer_slot.r()): display_in_detail = i
if curr_display_detail is not None:
with root.padding(0.2, 0.2) as slot:
draw_rectangle_rec(slot.r(), COLOUR_BACKGROUND)
outer_slot, glyph_slot = render_box_and_get_slot(slot)
render_glyph(glyph_slot, curr_display_detail)
if is_mouse_button_released(MouseButton.MOUSE_BUTTON_LEFT): display_in_detail = None
# TODO: Use glfw_set_char_callback for this instead
char = get_char_pressed()
while char > 0:
display_in_detail = get_glyph_index(file, chr(char))
char = get_char_pressed()
# draw_text(f"{scroll_y}", 50, 50, 50, GREEN) # debug for scrolling
end_drawing()
close_window()

96
OpenFontFormat/slot.py Normal file
View File

@ -0,0 +1,96 @@
from typing import Callable
from pyray import Rectangle, Vector2, get_screen_height, get_screen_width # type: ignore
Map = Callable[[float], float]
class Slot:
def __init__(self, x_map: Map, y_map: Map):
self.x = x_map
self.y = y_map
def v(self, v: Vector2) -> Vector2:
return Vector2(self.x(v.x), self.y(v.y))
def __enter__(self): return self
def __exit__(self, exc_type, exc_value, trace): pass
def vxy(self, x: float, y: float) -> Vector2:
return self.v(Vector2(x, y))
def r(self, r: Rectangle=Rectangle(0,0,1,1)) -> Rectangle:
x0 = self.x(r.x)
y0 = self.y(r.y)
x1 = self.x(r.x+r.width)
y1 = self.y(r.y+r.height)
x_min = min(x0, x1)
y_min = min(y0, y1)
x_max = max(x0, x1)
y_max = max(y0, y1)
return Rectangle(x_min, y_min, x_max-x_min, y_max-y_min)
@staticmethod
def get_root_slot(*, in_uv:bool=True) -> 'Slot':
"""
Returns the root slot for the window
"""
screen_width = get_screen_width()
screen_height = get_screen_height()
if in_uv:
return Slot(lambda x: x*screen_width, lambda y: y*screen_height)
else:
return Slot(lambda x: x, lambda y: y)
def between(self, x0: float, y0: float, x1: float, y1: float) -> 'Slot':
return Slot(lambda x: self.x(x*(x1-x0)+x0), lambda y: self.y(y*(y1-y0)+y0))
def remap(self, x0: float, y0: float, x1: float, y1: float) -> 'Slot':
"""
Returns the slot with the same region on the window but using a different coordinate space
"""
return Slot(lambda x: self.x((x-x0)/(x1-x0)), lambda y: self.y((y-y0)/(y1-y0)))
def grid(self, rows: int, cols: int, *, total_slots:int=-1) -> 'list[Slot]':
return self.extending_grid(rows, cols, rows, total_slots=total_slots)
def extending_grid(self, rows: int, cols: int, total_rows: int, *, total_slots:int=-1) -> 'list[Slot]':
slots: list[Slot] = []
if total_rows == -1:
assert total_slots != -1
total_rows = (total_slots+cols-1)//cols
for j in range(total_rows):
for i in range(cols):
slots.append(self.between(i/cols, j/rows, (i+1)/cols, (j+1)/rows))
if total_slots != -1: return slots[:total_slots]
return slots
def translate(self, x0: float, y0: float) -> 'Slot':
return self.between(x0, y0, x0+1, y0+1)
def square(self) -> 'Slot':
"""
Returns the square slot that takes up as much space as possible within parent slot
"""
return self.with_ratio(1)
def with_ratio(self, width_to_height_ratio: float) -> 'Slot':
width = self.x(1) - self.x(0)
height = self.y(1) - self.y(0)
if width == 0 or height == 0: return Slot(lambda x: self.x(0) + width/2, lambda y: self.y(0) + height/2)
if width/height > width_to_height_ratio:
ratio = height/width*width_to_height_ratio
left = (1-ratio)/2
return self.between(left, 0, 1-left, 1)
else:
ratio = width/height/width_to_height_ratio
up = (1-ratio)/2
return self.between(0, up, 1, 1-up)
def padding(self, horz_padding: float, vert_padding: float, *, width:float=1, height:float=1) -> 'Slot':
return self.between(horz_padding, vert_padding, width-horz_padding, height-horz_padding)

View File

@ -1,9 +1,9 @@
#!/usr/bin/env python3
import os
import sys
assert len(sys.argv) == 2, "usage: python3 test.py <test>"
assert len(sys.argv) >= 2, "usage: python3 test.py <test> [OPTIONS]"
from OpenFont import FontSpecificNameID, NameID, NameTable_Format_0, OpenFontFile, PredefinedNameID, TrueTypeOutlines, open_font_file, write_font_file
from OpenFont import FontSpecificNameID, NameID, NameTable_Format_0, OpenFontFile, PredefinedNameID, TrueTypeOutlines, open_font_file
def search_names(font: OpenFontFile, nameID: NameID) -> str:
assert isinstance(font.naming_table, NameTable_Format_0)
@ -15,7 +15,7 @@ def search_names(font: OpenFontFile, nameID: NameID) -> str:
assert False, f"Name not found: {nameID}"
_, test = sys.argv
_, test, *options = sys.argv
match test:
case "names":
@ -30,11 +30,6 @@ match test:
axis_names = [search_names(font, FontSpecificNameID(axis.axisNameID)) for axis in font.font_variations.font_variations.axes]
num_instances = font.font_variations.font_variations.instanceCount
print(f"\tAxes: [{', '.join(axis_names)}] ({num_instances} instances)")
case "rewrite":
def test_font(font: OpenFontFile):
PATH = "out.ttf"
write_font_file(font, PATH)
open_font_file(PATH)
case _:
assert False, f"Invalid test: '{test}'"
@ -44,13 +39,17 @@ if not os.path.exists(COMPLETED_PATH):
with open(COMPLETED_PATH, "r") as f: completed = f.read().split('\n')
def do_font(file: str):
file = file.strip()
if file in completed: return
try:
font = open_font_file(file)
test_font(font)
except AssertionError as err:
err.add_note(f"Failed: {file}")
raise err
if '--raise' in options:
err.add_note(f"Failed: {file}")
raise err
print(f"{file}{':' if '--no-colon' not in options else ''} {err}")
return
with open(COMPLETED_PATH, 'a') as f: f.write(file+'\n')
completed.append(file)
@ -58,5 +57,3 @@ assert not sys.stdin.isatty(), f"Do not run this program directly, instead pipe
for line in sys.stdin:
file = line.rstrip('\n')
do_font(file)
print("Done!")