You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
masque/masque/shapes/text.py

241 lines
7.7 KiB
Python

from typing import List, Tuple, Dict
import copy
import numpy
from numpy import pi, inf
from . import Shape, Polygon, normalized_shape_tuple
from .. import PatternError
from ..utils import is_scalar, vector2, get_bit, normalize_mirror
# Loaded on use:
# from freetype import Face
# from matplotlib.path import Path
__author__ = 'Jan Petykiewicz'
class Text(Shape):
"""
Text (to be printed e.g. as a set of polygons).
This is distinct from non-printed Label objects.
"""
__slots__ = ('_string', '_height', '_rotation', '_mirrored', 'font_path')
_string: str
_height: float
_rotation: float
_mirrored: List[str]
font_path: str
# vertices property
@property
def string(self) -> str:
return self._string
@string.setter
def string(self, val: str):
self._string = val
# Rotation property
@property
def rotation(self) -> float:
return self._rotation
@rotation.setter
def rotation(self, val: float):
if not is_scalar(val):
raise PatternError('Rotation must be a scalar')
self._rotation = val % (2 * pi)
# Height property
@property
def height(self) -> float:
return self._height
@height.setter
def height(self, val: float):
if not is_scalar(val):
raise PatternError('Height must be a scalar')
self._height = val
# Mirrored property
@property
def mirrored(self) -> List[bool]:
return self._mirrored
@mirrored.setter
def mirrored(self, val: List[bool]):
if is_scalar(val):
raise PatternError('Mirrored must be a 2-element list of booleans')
self._mirrored = list(val)
def __init__(self,
string: str,
height: float,
font_path: str,
offset: vector2 = (0.0, 0.0),
rotation: float = 0.0,
mirrored: Tuple[bool] = (False, False),
layer: int = 0,
dose: float = 1.0):
self.identifier = ()
self.offset = offset
self.layer = layer
self.dose = dose
self.string = string
self.height = height
self.rotation = rotation
self.font_path = font_path
self.mirrored = mirrored
def __deepcopy__(self, memo: Dict = None) -> 'Text':
memo = {} if memo is None else memo
new = copy.copy(self)
new._offset = self._offset.copy()
new._mirrored = copy.deepcopy(self._mirrored, memo)
return new
def to_polygons(self,
poly_num_points: int = None, # unused
poly_max_arclen: float = None, # unused
) -> List[Polygon]:
all_polygons = []
total_advance = 0
for char in self.string:
raw_polys, advance = get_char_as_polygons(self.font_path, char)
# Move these polygons to the right of the previous letter
for xys in raw_polys:
poly = Polygon(xys, dose=self.dose, layer=self.layer)
[poly.mirror(ax) for ax, do in enumerate(self.mirrored) if do]
poly.scale_by(self.height)
poly.offset = self.offset + [total_advance, 0]
poly.rotate_around(self.offset, self.rotation)
all_polygons += [poly]
# Update the list of all polygons and how far to advance
total_advance += advance * self.height
return all_polygons
def rotate(self, theta: float) -> 'Text':
self.rotation += theta
return self
def mirror(self, axis: int) -> 'Text':
self.mirrored[axis] = not self.mirrored[axis]
return self
def scale_by(self, c: float) -> 'Text':
self.height *= c
return self
def normalized_form(self, norm_value: float) -> normalized_shape_tuple:
mirror_x, rotation = normalize_mirror(self.mirrored)
rotation += self.rotation
rotation %= 2 * pi
return (type(self), self.string, self.font_path, self.layer), \
(self.offset, self.height / norm_value, rotation, mirror_x, self.dose), \
lambda: Text(string=self.string,
height=self.height * norm_value,
font_path=self.font_path,
rotation=rotation,
mirrored=(mirror_x, False),
layer=self.layer)
def get_bounds(self) -> numpy.ndarray:
# rotation makes this a huge pain when using slot.advance and glyph.bbox(), so
# just convert to polygons instead
bounds = numpy.array([[+inf, +inf], [-inf, -inf]])
polys = self.to_polygons()
for poly in polys:
poly_bounds = poly.get_bounds()
bounds[0, :] = numpy.minimum(bounds[0, :], poly_bounds[0, :])
bounds[1, :] = numpy.maximum(bounds[1, :], poly_bounds[1, :])
return bounds
def get_char_as_polygons(font_path: str,
char: str,
resolution: float = 48*64,
) -> Tuple[List[List[List[float]]], float]:
from freetype import Face
from matplotlib.path import Path
"""
Get a list of polygons representing a single character.
The output is normalized so that the font size is 1 unit.
:param font_path: File path specifying a font loadable by freetype
:param char: Character to convert to polygons
:param resolution: Internal resolution setting (used for freetype
Face.set_font_size(resolution)). Modify at your own peril!
:return: List of polygons [[[x0, y0], [x1, y1], ...], ...] and 'advance' distance (distance
from the start of this glyph to the start of the next one)
"""
if len(char) != 1:
raise Exception('get_char_as_polygons called with non-char')
face = Face(font_path)
face.set_char_size(resolution)
face.load_char(char)
slot = face.glyph
outline = slot.outline
start = 0
all_verts, all_codes = [], []
for end in outline.contours:
points = outline.points[start:end + 1]
points.append(points[0])
tags = outline.tags[start:end + 1]
tags.append(tags[0])
segments = []
for j, point in enumerate(points):
# If we already have a segment, add this point to it
if j > 0:
segments[-1].append(point)
# If not bezier control point, start next segment
if get_bit(tags[j], 0) and j < (len(points) - 1):
segments.append([point])
verts = [points[0]]
codes = [Path.MOVETO]
for segment in segments:
if len(segment) == 2:
verts.extend(segment[1:])
codes.extend([Path.LINETO])
elif len(segment) == 3:
verts.extend(segment[1:])
codes.extend([Path.CURVE3, Path.CURVE3])
else:
verts.append(segment[1])
codes.append(Path.CURVE3)
for i in range(1, len(segment) - 2):
a, b = segment[i], segment[i + 1]
c = ((a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0)
verts.extend([c, b])
codes.extend([Path.CURVE3, Path.CURVE3])
verts.append(segment[-1])
codes.append(Path.CURVE3)
all_verts.extend(verts)
all_codes.extend(codes)
start = end + 1
all_verts = numpy.array(all_verts) / resolution
advance = slot.advance.x / resolution
if len(all_verts) == 0:
polygons = []
else:
path = Path(all_verts, all_codes)
path.should_simplify = False
polygons = path.to_polygons()
return polygons, advance