Skip to content

Instantly share code, notes, and snippets.

@typoman
Last active September 18, 2024 10:54
Show Gist options
  • Save typoman/8eee380080320ad401cad4a86e969a2b to your computer and use it in GitHub Desktop.
Save typoman/8eee380080320ad401cad4a86e969a2b to your computer and use it in GitHub Desktop.
A pen class for converting glyph outlines into symbolic representations. The resulting string can be used for quick visual comparison or analysis of glyph shapes without rendering the full outline.
from typing import Tuple, Optional
from fontTools.pens.basePen import BasePen
from fontTools.misc.arrayTools import calcBounds
import math
def round_angle_to_step(angle: float, degree: int) -> int:
"""
Rounds an angle to the nearest degree increment.
Args:
angle (float): The angle to be rounded, in degrees.
degree (int): The degree increment to round to.
Returns:
int: The rounded angle, in degrees.
# Angle that is exactly divisible by the degree increment
>>> round_angle_to_step(90, 30)
90
# Angle that is not exactly divisible by the degree increment
>>> round_angle_to_step(91, 45)
90
# Angle that is greater than 360 degrees
>>> round_angle_to_step(370, 22)
0
# Angle that is less than 0 degrees
>>> round_angle_to_step(-10, 30)
360
# Angle that is a multiple of 360 degrees
>>> round_angle_to_step(721, 45)
0
# Degree increment of 1
>>> round_angle_to_step(91.5, 1)
92
# Degree increment of 360
>>> round_angle_to_step(91, 360)
0
# Zero angle
>>> round_angle_to_step(0, 30)
0
"""
return round((angle % 360) / degree) * degree
def get_abs_vector_angle(dx: float, dy: float) -> float:
"""
Calculates the absolute angle in degrees from the given vector
relative to the positive x-axis (considered to be 0 degree).
Args:
dx (float): The x component of the vector.
dy (float): The y component of the vector.
Returns:
float: The absolute angle in degrees, normalized to the range [0, 360).
# Positive x and y components
>>> get_abs_vector_angle(3, 4)
53.13010235415595
# Zero x component, positive y component
>>> get_abs_vector_angle(0, 4)
90.0
# Zero x component, negative y component
>>> get_abs_vector_angle(0, -4)
270.0
# Positive x component, zero y component
>>> get_abs_vector_angle(4, 0)
0.0
# Negative x component, zero y component
>>> get_abs_vector_angle(-4, 0)
180.0
# Positive x and y components, angle greater than 90 degrees
>>> get_abs_vector_angle(3, 5)
59.03624346792651
# Negative x and y components, angle greater than 180 degrees
>>> get_abs_vector_angle(-3, -4)
233.13010235415598
# Zero x and y components
>>> get_abs_vector_angle(0, 0)
0.0
"""
angle = math.atan2(dy, dx)
angle_degrees = math.degrees(angle)
angle_normalized = (angle_degrees + 360) % 360
return angle_normalized
def vector_is_zero(dx: float, dy: float) -> bool:
"""
# Non-zero vector
>>> vector_is_zero(1.0, 1.0)
False
# Vector with small but non-zero components
>>> vector_is_zero(1e-11, 1e-11)
True
"""
return abs(dx) < 1e-10 and abs(dy) < 1e-10
def get_vector_direction_char_from_angle(angle_normalized: float) -> str:
"""
Returns the direction character of a vector based on its angle.
The function takes an angle in degrees and returns a character representing
the direction of the vector. The direction is determined by dividing the
circle into eight octants, each corresponding to a different direction.
Args:
angle_normalized (float): The angle of the vector in degrees.
Returns:
str: A character representing the direction of the vector.
>>> get_vector_direction_char_from_angle(45)
'↗'
>>> get_vector_direction_char_from_angle(90)
'↑'
>>> get_vector_direction_char_from_angle(135)
'↖'
>>> get_vector_direction_char_from_angle(180)
'←'
>>> get_vector_direction_char_from_angle(225)
'↙'
>>> get_vector_direction_char_from_angle(270)
'↓'
>>> get_vector_direction_char_from_angle(315)
'↘'
>>> get_vector_direction_char_from_angle(11.5)
'→'
>>> get_vector_direction_char_from_angle(67.5)
'↑'
>>> get_vector_direction_char_from_angle(112.5)
'↖'
>>> get_vector_direction_char_from_angle(157.5)
'←'
>>> get_vector_direction_char_from_angle(202.5)
'↙'
>>> get_vector_direction_char_from_angle(247.5)
'↓'
>>> get_vector_direction_char_from_angle(292.5)
'↘'
>>> get_vector_direction_char_from_angle(337.5)
'→'
"""
octant = int(((angle_normalized + 22.5) % 360) / 45)
directions = ("→", "↗", "↑", "↖", "←", "↙", "↓", "↘")
return directions[octant]
def get_scaling_factors(x0: float, y0: float, x1: float, y1: float) -> Tuple[float, float]:
"""
Calculates the scaling factors for a rectangle to make it a square.
Args:
x0 (float or int): The x-coordinate of the top-left corner of the rectangle.
y0 (float or int): The y-coordinate of the top-left corner of the rectangle.
x1 (float or int): The x-coordinate of the bottom-right corner of the rectangle.
y1 (float or int): The y-coordinate of the bottom-right corner of the rectangle.
Returns:
tuple: A tuple containing the scaling factors for the x and y axes.
# Square input
>>> get_scaling_factors(0, 0, 10, 10)
(1.0, 1.0)
# Rectangle with width > height
>>> get_scaling_factors(0, 0, 10, 5)
(1.0, 0.5)
# Rectangle with height > width
>>> get_scaling_factors(0, 0, 5, 10)
(0.5, 1.0)
# Negative coordinates
>>> get_scaling_factors(-10, -10, 10, 10)
(1.0, 1.0)
# Non-integer coordinates
>>> get_scaling_factors(0.5, 0.5, 10.5, 10.5)
(1.0, 1.0)
# Identical coordinates
>>> get_scaling_factors(10, 10, 10, 10)
Traceback (most recent call last):
...
ValueError: Both width and height are zero, cannot calculate scaling factors.
# Large coordinates
>>> get_scaling_factors(1000, 1000, 2000, 2000)
(1.0, 1.0)
"""
width = abs(x1 - x0)
height = abs(y1 - y0)
if width == 0 and height == 0:
raise ValueError("Both width and height are zero, cannot calculate scaling factors.")
# Determine the scaling factors
if width > height:
scale_x = 1.0
scale_y = height / width
else:
scale_x = width / height
scale_y = 1.0
return (scale_x, scale_y)
curve_dir_2_string = {
# straight circle quarters
"↑←": "◝",
"↓→": "◟",
"↑→": "◜",
"↓←": "◞",
"←↓": "◜",
"←↑": "◟",
"→↓": "◝",
"→↑": "◞",
# diagonal quarter or smaller arcs
"↖←": "◝",
"↙←": "◞",
"↗→": "◜",
"↘→": "◟",
"→↗": "◞",
"→↘": "◝",
"↓↘": "◟",
"↑↗": "◜",
"↓↙": "◞",
"↑↖": "◝",
"↘↓": "◝",
"↗↑": "◞",
"←↖": "◟",
"←↙": "◜",
"↙↓": "◜",
"↖↑": "◟",
"↗↙": "◝",
"↙↗": "◟",
"↖↘": "◜",
"↘↖": "◞",
# half circles or bigger arcs
"↙↖": "◡",
"↘↗": "◡",
"↗↘": "◠",
"↖↙": "◠",
"↘↙": "﹚",
"↗↖": "﹚",
"↙↘": "﹙",
"↖↗": "﹙",
"↑↘": "◠",
"↑↙": "◠",
"↓↖": "◡",
"↓↗": "◡",
"←↗": "﹙",
"←↘": "﹙",
"→↖": "﹚",
"→↙": "﹚",
"↖↓": "◠",
"↘↑": "◡",
"↙↑": "◡",
"↗↓": "◠",
"→←": "﹚",
"←→": "﹙",
"↑↓": "◠",
"↓↑": "◡",
"↘←": "﹚",
"↖→": "﹙",
"↙→": "﹙",
"↗←": "﹚",
}
wavy_curve_dir_string = {
"↑": "≀",
"↓": "≀",
"→": "∿",
"←": "∿",
}
flat_curve_dir_string = {
"↑": "↟",
"↓": "↡",
"→": "↠",
"←": "↞",
}
def rotation_is_clockwise(v1: Tuple[float, float], v2: Tuple[float, float]) -> Optional[bool]:
"""
Determines if the rotation from one 2D vector to another is clockwise. This
function calculates the cross product of two 2D vectors and uses the result
to determine the direction of rotation.
Args:
v1 (list): The initial 2D vector, represented as [x, y].
v2 (list): The final 2D vector, represented as [x, y].
Returns:
bool: True if the rotation is clockwise, False if counterclockwise, and
None if the vectors are collinear.
# Clockwise rotation
>>> v1 = [1, 0]
>>> v2 = [0, 1]
>>> rotation_is_clockwise(v1, v2)
False
# Counterclockwise rotation
>>> v1 = [1, 0]
>>> v2 = [0, -1]
>>> rotation_is_clockwise(v1, v2)
True
# Collinear vectors
>>> v1 = [1, 0]
>>> v2 = [2, 0]
>>> rotation_is_clockwise(v1, v2)
# Zero vector
>>> v1 = [0, 0]
>>> v2 = [1, 0]
>>> rotation_is_clockwise(v1, v2)
# Vectors with same direction but different magnitude
>>> v1 = [1, 0]
>>> v2 = [3, 0]
>>> rotation_is_clockwise(v1, v2)
"""
cross_product = v1[0] * v2[1] - v1[1] * v2[0]
if cross_product > 0:
return False
elif cross_product < 0:
return True
return None
class SegmentSymbolsPen(BasePen):
"""
A pen class for converting glyph outlines into symbolic representations.
This class extends BasePen to create a string representation of glyph
outlines using directional symbols. It processes moveTo, lineTo, and
curveTo commands, converting them into a sequence of characters that
represent the path's direction and curvature. Contours are separated
by newlines.
The resulting string can be used for quick visual comparison or analysis
of glyph shapes without rendering the full outline.
Attributes:
segments_symbols: complete symbolic representation of all segments as a string
Examples:
# horizontal line
>>> pen = SegmentSymbolsPen()
>>> pen._moveTo((0, 0))
>>> pen._lineTo((1, 0))
>>> pen.segments_symbols
'→'
# vertical line
>>> pen = SegmentSymbolsPen()
>>> pen._moveTo((0, 0))
>>> pen._lineTo((0, 1))
>>> pen.segments_symbols
'↑'
# diagonal line
>>> pen = SegmentSymbolsPen()
>>> pen._moveTo((0, 0))
>>> pen._lineTo((1, 1))
>>> pen.segments_symbols
'↗'
# zero-length line
>>> pen = SegmentSymbolsPen()
>>> pen._moveTo((0, 0))
>>> pen._lineTo((0, 0))
>>> pen.segments_symbols
'.'
# top side curve
>>> pen = SegmentSymbolsPen()
>>> pen._moveTo((0, 0))
>>> pen._curveToOne((0, 1), (1, 1), (1, 0))
>>> pen.segments_symbols
'◠'
# zero length curve
>>> pen = SegmentSymbolsPen()
>>> pen._moveTo((0, 0))
>>> pen._curveToOne((1, 0), (1, 0), (0, 0))
>>> pen.segments_symbols
'⟳'
# left side curve
>>> pen = SegmentSymbolsPen()
>>> pen._moveTo((0, 0))
>>> pen._curveToOne((1, 0), (0.5, 0.5), (0, 1))
>>> pen.segments_symbols
'﹚'
# close path
>>> pen._closePath()
>>> pen.segments_symbols
'﹚↓'
# line directions
>>> pen = SegmentSymbolsPen()
>>> pen._moveTo((0, 0))
>>> pen._lineTo((1, 0)) # right
>>> pen._lineTo((1, 1)) # up-right
>>> pen._lineTo((0, 1)) # up
>>> pen._lineTo((-1, 1)) # up-left
>>> pen._lineTo((-1, 0)) # left
>>> pen._lineTo((-1, -1)) # down-left
>>> pen._lineTo((0, -1)) # down
>>> pen._lineTo((1, -1)) # down-right
>>> pen._closePath()
>>> pen.segments_symbols
'→↑←←↓↓→→↖'
# some curve directions
>>> pen = SegmentSymbolsPen()
>>> pen._moveTo((0, 0))
>>> pen._curveToOne((1, 2), (2, 2), (3, 0)) # top curve
>>> pen._curveToOne((4, -2), (5, -2), (6, 0)) # bottom curve
>>> pen._curveToOne((8, 0), (8, 2), (6, 2)) # right curve
>>> pen._curveToOne((4, 2), (4, 0), (6, 0)) # left curve
>>> pen._closePath()
>>> pen.segments_symbols
'◠◡﹚﹙←'
>>> pen = SegmentSymbolsPen()
>>> pen._moveTo((10, 0))
>>> pen._curveToOne((0, 0), (0, 0), (0, 10))
>>> pen._curveToOne((0, 20), (0, 20), (10, 20))
>>> pen._curveToOne((20, 20), (20, 20), (20, 10))
>>> pen._curveToOne((20, 0), (20, 0), (10, 0))
>>> pen.segments_symbols
'◟◜◝◞'
# wavy and flat curves
>>> pen = SegmentSymbolsPen()
>>> pen._moveTo((0, 0))
>>> pen._curveToOne((1, 0.1), (2, -0.1), (3, 0)) # wavy horizontal curve
>>> pen._lineTo((0, 0))
>>> pen._curveToOne((0.1, 1), (-0.1, 2), (0, 3)) # wavy vertical curve
>>> pen._lineTo((2, 3))
>>> pen._curveToOne((2, 3), (1, 3), (0, 3)) # flat horizontal curve
>>> pen._curveToOne((0, 2), (0, 1), (0, 0)) # flat vertical curve
>>> pen._closePath()
>>> pen.segments_symbols
'∿←≀→↞↡.'
# multiple contours
>>> pen = SegmentSymbolsPen()
>>> # First contour (square)
>>> pen._moveTo((0, 0))
>>> pen._lineTo((0, 1))
>>> pen._lineTo((1, 1))
>>> pen._lineTo((1, 0))
>>> pen._closePath()
>>> # Second contour (triangle)
>>> pen._moveTo((0.25, 0.25))
>>> pen._lineTo((0.75, 0.25))
>>> pen._lineTo((0.5, 0.75))
>>> pen._closePath()
>>> pen.segments_symbols
'↑→↓←\\n→↑↙'
"""
def __init__(self, glyphSet=None):
super().__init__(glyphSet)
self._path = []
self._start_point = None
self._current_point = None
def _moveTo(self, pt):
if self._path != []:
self._path.append("\n")
x, y = self._round(pt)
self._start_point = (x, y)
self._current_point = (x, y)
def _lineTo(self, pt):
x1, y1 = pt
x2, y2 = self._current_point
self._current_point = pt
dx, dy = self._round((x1 - x2, y1 - y2))
if vector_is_zero(dx, dy):
char = "."
else:
angle = get_abs_vector_angle(dx, dy)
char = get_vector_direction_char_from_angle(angle)
self._path.append(char)
def _curveToOne(self, pt1, pt2, pt3):
p0 = self._current_point # start of first tangent
p1 = self._round(pt1) # end of first tangent
p2 = self._round(pt2) # start of last tangent
p3 = self._round(pt3) # end of last tangent
self._current_point = pt3
# here I scale the tangents to fit inside a square,
# this helps to get a more relevant character.
all_points = (p0, p1, p2, p3)
bounds = calcBounds(all_points)
sx, sy = get_scaling_factors(*bounds)
if 0 not in (sx, sy):
all_points = map(lambda pt: (pt[0] / sx, pt[1] / sy), all_points)
p0, p1, p2, p3 = all_points
x0, y0 = p0
x1, y1 = p1
x2, y2 = p2
x3, y3 = p3
# segment vector
dx3 = x3 - x0
dy3 = y3 - y0
if vector_is_zero(dx3, dy3):
# zero length curve
self._path.append("⟳")
return
# tangent 1 vector
dx1 = x1 - x0
dy1 = y1 - y0
if vector_is_zero(dx1, dy1):
dx1, dy1 = dx3, dy3
# tangent 2 vector
dx2 = x3 - x2
dy2 = y3 - y2
if vector_is_zero(dx2, dy2):
dx2, dy2 = dx3, dy3
t1_clockwise = rotation_is_clockwise((dx3, dy3), (dx2, dy2))
t2_clockwise = rotation_is_clockwise((dx3, dy3), (dx1, dy1))
# check if tangents are too close in terms of angle almost forming a line
t1_angle = get_abs_vector_angle(dx1, dy1)
t2_angle = get_abs_vector_angle(dx2, dy2)
da = t1_angle - t2_angle
if abs(da) < 45:
if t1_clockwise == t2_clockwise:
if sx > sy:
dy3 = 0
else:
dx3 = 0
# segment dir symbol
s_char = get_vector_direction_char_from_angle(get_abs_vector_angle(dx3, dy3))
if t1_clockwise is None:
# absolutely flat curve, rare and weird!
self._path.append(flat_curve_dir_string[s_char])
else:
# wavy curve
self._path.append(wavy_curve_dir_string[s_char])
return
t1_angle = round_angle_to_step(t1_angle, 90)
t2_angle = round_angle_to_step(t2_angle, 90)
t1_char = get_vector_direction_char_from_angle(t1_angle)
t2_char = get_vector_direction_char_from_angle(t2_angle)
# print(t1_char, t2_char)
abs_curve_dir = t1_char + t2_char
self._path.append(curve_dir_2_string[abs_curve_dir])
def _closePath(self):
if self._start_point:
self._lineTo(self._start_point)
self._start_point = None
self._current_point = None
def _endPath(self):
self._start_point = None
self._current_point = None
def _round(self, pt):
return tuple(round(v, 1) for v in pt)
@property
def segments_symbols(self) -> str:
return "".join(self._path)
def _test_more_arcs(start, control1, control2, end):
"""
>>> _test_more_arcs((0, 0), (-1, 1), (-1, 1), (-2, 1))
'◝'
>>> _test_more_arcs((0, 0), (-1, -1), (-1, -1), (-2, -1))
'◞'
>>> _test_more_arcs((0, 0), (1, 1), (1, 1), (2, 1))
'◜'
>>> _test_more_arcs((0, 0), (1, -1), (1, -1), (2, -1))
'◟'
>>> _test_more_arcs((0, 0), (2, 0), (2, 0), (2, 1))
'◞'
>>> _test_more_arcs((0, 0), (2, 0), (2, 0), (2, -1))
'◝'
>>> _test_more_arcs((0, 0), (0, -2), (0, -2), (1, -2))
'◟'
>>> _test_more_arcs((0, 0), (0, 2), (0, 2), (1, 2))
'◜'
>>> _test_more_arcs((0, 0), (0, -2), (0, -2), (-1, -2))
'◞'
>>> _test_more_arcs((0, 0), (0, 2), (0, 2), (-1, 2))
'◝'
>>> _test_more_arcs((0, 0), (1, -1), (1, -1), (1, -2))
'◝'
>>> _test_more_arcs((0, 0), (1, 1), (1, 1), (1, 2))
'◞'
>>> _test_more_arcs((0, 0), (-2, 0), (-2, 0), (-2, 1))
'◟'
>>> _test_more_arcs((0, 0), (-2, 0), (-2, 0), (-2, -1))
'◜'
>>> _test_more_arcs((0, 0), (-1, -1), (-1, -1), (-1, -2))
'◜'
>>> _test_more_arcs((0, 0), (-1, 1), (-1, 1), (-1, 2))
'◟'
>>> _test_more_arcs((0, 0), (1, 1), (1, 1), (-1, -1))
'◝'
>>> _test_more_arcs((0, 0), (-1, -1), (-1, -1), (1, 1))
'◟'
>>> _test_more_arcs((0, 0), (-1, 1), (-1, 1), (1, -1))
'◜'
>>> _test_more_arcs((0, 0), (1, -1), (1, -1), (-1, 1))
'◞'
>>> _test_more_arcs((0, 0), (-1, -1), (-1, -1), (-1, 1))
'◡'
>>> _test_more_arcs((0, 0), (1, -1), (1, -1), (1, 1))
'◡'
>>> _test_more_arcs((0, 0), (1, 1), (1, 1), (1, -1))
'◠'
>>> _test_more_arcs((0, 0), (-1, 1), (-1, 1), (-1, -1))
'◠'
>>> _test_more_arcs((0, 0), (1, -1), (1, -1), (-1, -1))
'﹚'
>>> _test_more_arcs((0, 0), (1, 1), (1, 1), (-1, 1))
'﹚'
>>> _test_more_arcs((0, 0), (-1, -1), (-1, -1), (1, -1))
'﹙'
>>> _test_more_arcs((0, 0), (-1, 1), (-1, 1), (1, 1))
'﹙'
>>> _test_more_arcs((0, 0), (0, 2), (0, 2), (2, -2))
'◠'
>>> _test_more_arcs((0, 0), (0, 2), (0, 2), (-2, -2))
'◠'
>>> _test_more_arcs((0, 0), (0, -2), (0, -2), (-2, 2))
'◡'
>>> _test_more_arcs((0, 0), (0, -2), (0, -2), (2, 2))
'◡'
>>> _test_more_arcs((0, 0), (-2, 0), (-2, 0), (2, 2))
'﹙'
>>> _test_more_arcs((0, 0), (-2, 0), (-2, 0), (2, -2))
'﹙'
>>> _test_more_arcs((0, 0), (2, 0), (2, 0), (-2, 2))
'﹚'
>>> _test_more_arcs((0, 0), (2, 0), (2, 0), (-2, -2))
'﹚'
>>> _test_more_arcs((0, 0), (-1, 1), (-1, 1), (-1, -1))
'◠'
>>> _test_more_arcs((0, 0), (1, -1), (1, -1), (1, 1))
'◡'
>>> _test_more_arcs((0, 0), (-1, -1), (-1, -1), (-1, 1))
'◡'
>>> _test_more_arcs((0, 0), (1, 1), (1, 1), (1, -1))
'◠'
>>> _test_more_arcs((0, 0), (2, 0), (2, 0), (-2, 0))
'﹚'
>>> _test_more_arcs((0, 0), (-2, 0), (-2, 0), (2, 0))
'﹙'
>>> _test_more_arcs((0, 0), (0, 2), (0, 2), (0, -2))
'◠'
>>> _test_more_arcs((0, 0), (0, -2), (1, -2), (1, 2))
'◡'
>>> _test_more_arcs((0, 0), (1, -1), (1, -1), (-1, -1))
'﹚'
>>> _test_more_arcs((0, 0), (-1, 1), (-1, 1), (1, 1))
'﹙'
>>> _test_more_arcs((0, 0), (-1, -1), (-1, -1), (1, -1))
'﹙'
>>> _test_more_arcs((0, 0), (1, 1), (1, 1), (-1, 1))
'﹚'
"""
pen = SegmentSymbolsPen()
pen._moveTo(start)
pen._curveToOne(control1, control2, end)
return pen.segments_symbols
# for diagnosing
# def draw_curve(start, control1, control2, end):
# fill(None)
# stroke(0)
# pen = BezierPath()
# pen.moveTo(start)
# pen.curveTo(control1, control2, end)
# drawPath(pen)
if __name__ == "__main__":
import doctest
doctest.testmod()
@typoman
Copy link
Author

typoman commented Sep 17, 2024

For visualizng the _test_more_arcs in drawbot:

pts = (0, 0), (2, 0), (2, 0), (-2, -2)

def draw_curve(start, control1, control2, end):
    fill(None)
    stroke(0)
    pen = BezierPath()
    pen.moveTo(start)
    pen.curveTo(control1, control2, end)
    drawPath(pen)

translate(100, 100)
scale(10,10)
draw_curve(*pts)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment