masque/masque/shapes/text.py

287 lines
9.5 KiB
Python
Raw Normal View History

from typing import Self, Any, cast
import copy
import functools
import numpy
from numpy import pi, nan
from numpy.typing import NDArray, ArrayLike
2016-03-15 19:12:39 -07:00
from . import Shape, Polygon, normalized_shape_tuple
from ..error import PatternError
2020-07-22 21:50:39 -07:00
from ..repetition import Repetition
from ..traits import RotatableImpl
from ..utils import is_scalar, get_bit, annotations_t, annotations_lt, annotations_eq, rep2key
# Loaded on use:
# from freetype import Face
# from matplotlib.path import Path
@functools.total_ordering
class Text(RotatableImpl, Shape):
"""
Text (to be printed e.g. as a set of polygons).
This is distinct from non-printed Label objects.
"""
2023-01-19 22:20:16 -08:00
__slots__ = (
'_string', '_height', '_mirrored', 'font_path',
# Inherited
2023-04-12 13:56:50 -07:00
'_offset', '_repetition', '_annotations', '_rotation',
2023-01-19 22:20:16 -08:00
)
_string: str
_height: float
_mirrored: bool
font_path: str
# vertices property
@property
def string(self) -> str:
return self._string
@string.setter
def string(self, val: str) -> None:
self._string = val
# Height property
@property
def height(self) -> float:
return self._height
@height.setter
def height(self, val: float) -> None:
if not is_scalar(val):
raise PatternError('Height must be a scalar')
self._height = val
@property
def mirrored(self) -> bool: # mypy#3004, should be bool
return self._mirrored
@mirrored.setter
def mirrored(self, val: bool) -> None:
self._mirrored = bool(val)
def __init__(
self,
string: str,
height: float,
font_path: str,
*,
offset: ArrayLike = (0.0, 0.0),
rotation: float = 0.0,
2023-02-23 13:15:32 -08:00
repetition: Repetition | None = None,
annotations: annotations_t = None,
raw: bool = False,
) -> None:
if raw:
2023-01-23 22:27:26 -08:00
assert isinstance(offset, numpy.ndarray)
self._offset = offset
self._string = string
self._height = height
self._rotation = rotation
self._repetition = repetition
self._annotations = annotations
else:
self.offset = offset
self.string = string
self.height = height
self.rotation = rotation
self.repetition = repetition
self.annotations = annotations
self.font_path = font_path
def __deepcopy__(self, memo: dict | None = None) -> Self:
memo = {} if memo is None else memo
2022-04-17 19:04:13 -07:00
new = copy.copy(self)
new._offset = self._offset.copy()
new._annotations = copy.deepcopy(self._annotations)
return new
def __eq__(self, other: Any) -> bool:
return (
type(self) is type(other)
and numpy.array_equal(self.offset, other.offset)
and self.string == other.string
and self.height == other.height
and self.font_path == other.font_path
and self.rotation == other.rotation
and self.repetition == other.repetition
and annotations_eq(self.annotations, other.annotations)
)
def __lt__(self, other: Shape) -> bool:
if type(self) is not type(other):
if repr(type(self)) != repr(type(other)):
return repr(type(self)) < repr(type(other))
return id(type(self)) < id(type(other))
other = cast('Text', other)
if not self.height == other.height:
return self.height < other.height
if not self.string == other.string:
return self.string < other.string
if not self.font_path == other.font_path:
return self.font_path < other.font_path
if not numpy.array_equal(self.offset, other.offset):
return tuple(self.offset) < tuple(other.offset)
if self.rotation != other.rotation:
return self.rotation < other.rotation
if self.repetition != other.repetition:
return rep2key(self.repetition) < rep2key(other.repetition)
return annotations_lt(self.annotations, other.annotations)
def to_polygons(
self,
2024-07-28 20:04:15 -07:00
num_vertices: int | None = None, # unused # noqa: ARG002
max_arclen: float | None = None, # unused # noqa: ARG002
2023-02-23 13:15:32 -08:00
) -> list[Polygon]:
all_polygons = []
total_advance = 0.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:
2023-04-12 13:56:50 -07:00
poly = Polygon(xys)
if self.mirrored:
poly.mirror()
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 mirror(self, axis: int = 0) -> Self:
self.mirrored = not self.mirrored
if axis == 1:
self.rotation += pi
return self
def scale_by(self, c: float) -> Self:
self.height *= c
return self
def normalized_form(self, norm_value: float) -> normalized_shape_tuple:
rotation = self.rotation % (2 * pi)
2023-04-12 13:56:50 -07:00
return ((type(self), self.string, self.font_path),
(self.offset, self.height / norm_value, rotation, bool(self.mirrored)),
2023-02-23 11:25:40 -08:00
lambda: Text(
string=self.string,
height=self.height * norm_value,
font_path=self.font_path,
rotation=rotation,
).mirror2d(across_x=self.mirrored),
)
def get_bounds_single(self) -> NDArray[numpy.float64]:
# rotation makes this a huge pain when using slot.advance and glyph.bbox(), so
# just convert to polygons instead
polys = self.to_polygons()
pbounds = numpy.full((len(polys), 2, 2), nan)
for pp, poly in enumerate(polys):
pbounds[pp] = poly.get_bounds_nonempty()
bounds = numpy.vstack((
numpy.min(pbounds[: 0, :], axis=0),
numpy.max(pbounds[: 1, :], axis=0),
))
return bounds
2024-07-28 19:35:44 -07:00
def __repr__(self) -> str:
rotation = f'{numpy.rad2deg(self.rotation):g}' if self.rotation != 0 else ''
mirrored = ' m{:d}' if self.mirrored else ''
return f'<TextShape "{self.string}" o{self.offset} h{self.height:g}{rotation}{mirrored}>'
def get_char_as_polygons(
font_path: str,
char: str,
resolution: float = 48 * 64,
2023-02-23 13:15:32 -08:00
) -> tuple[list[list[list[float]]], float]:
from freetype import Face # type: ignore
from matplotlib.path import Path # type: ignore
"""
Get a list of polygons representing a single character.
The output is normalized so that the font size is 1 unit.
Args:
font_path: File path specifying a font loadable by freetype
char: Character to convert to polygons
resolution: Internal resolution setting (used for freetype
`Face.set_font_size(resolution))`. Modify at your own peril!
Returns:
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:
2024-07-28 20:08:53 -07:00
raise PatternError('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
2024-07-29 03:13:36 -07:00
all_verts_list = []
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])
2023-02-23 13:15:32 -08:00
segments: list[list[list[float]]] = []
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_list.extend(verts)
all_codes.extend(codes)
start = end + 1
all_verts = numpy.array(all_verts_list) / 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