diff --git a/README.md b/README.md index 2bec3e3..d8b54cb 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,10 @@ E-beam doses, and the ability to output to multiple formats. Requirements: * python 3 (written and tested with 3.5) * numpy -* matplotlib (optional, used for visualization functions) +* matplotlib (optional, used for visualization functions and text) * python-gdsii (optional, used for gdsii i/o) * svgwrite (optional, used for svg output) +* freetype (optional, used for text) Install with pip, via git: diff --git a/masque/shapes/__init__.py b/masque/shapes/__init__.py index d00ee96..4c64204 100644 --- a/masque/shapes/__init__.py +++ b/masque/shapes/__init__.py @@ -9,4 +9,4 @@ from .polygon import Polygon from .circle import Circle from .ellipse import Ellipse from .arc import Arc - +from .text import Text diff --git a/masque/shapes/text.py b/masque/shapes/text.py index de2eae1..e645920 100644 --- a/masque/shapes/text.py +++ b/masque/shapes/text.py @@ -1,57 +1,201 @@ +from typing import List, Tuple +import numpy +from numpy import pi, inf -# -# class Text(Shape): -# _string = '' -# _height = 1.0 -# _rotation = 0.0 -# font_path = '' -# -# # vertices property -# @property -# def string(self): -# return self._string -# -# @string.setter -# def string(self, val): -# self._string = val -# -# # Rotation property -# @property -# def rotation(self): -# return self._rotation -# -# @rotation.setter -# def rotation(self, val): -# if not is_scalar(val): -# raise PatternError('Rotation must be a scalar') -# self._rotation = val % (2 * pi) -# -# # Height property -# @property -# def height(self): -# return self._height -# -# @height.setter -# def height(self, val): -# if not is_scalar(val): -# raise PatternError('Height must be a scalar') -# self._height = val -# -# def __init__(self, text, height, font_path, rotation=0.0, offset=(0.0, 0.0), layer=0, dose=1.0): -# self.offset = offset -# self.layer = layer -# self.dose = dose -# self.text = text -# self.height = height -# self.rotation = rotation -# self.font_path = font_path -# -# def to_polygon(self, _poly_num_points=None, _poly_max_arclen=None): -# -# return copy.deepcopy(self) -# -# def rotate(self, theta): -# self.rotation += theta -# -# def scale_by(self, c): -# self.height *= c +from . import Shape, Polygon, normalized_shape_tuple +from .. import PatternError +from ..utils import is_scalar, vector2, get_bit + +# Loaded on use: +# from freetype import Face +# from matplotlib.path import Path + + +__author__ = 'Jan Petykiewicz' + + +class Text(Shape): + _string = '' + _height = 1.0 + _rotation = 0.0 + font_path = '' + + # 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 + + def __init__(self, + string: str, + height: float, + font_path: str, + rotation: float=0.0, + offset: vector2=(0.0, 0.0), + layer: int=0, + dose: float=1.0): + self.offset = offset + self.layer = layer + self.dose = dose + self.string = string + self.height = height + self.rotation = rotation + self.font_path = font_path + + def to_polygons(self, + _poly_num_points: int=None, + _poly_max_arclen: float=None + ) -> 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.scale_by(self.height) + poly.offset = self.offset + [total_advance, 0] + # poly.scale_by(self.height) + 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): + self.rotation += theta + + def scale_by(self, c: float): + self.height *= c + + def normalized_form(self, norm_value: float) -> normalized_shape_tuple: + return (type(self), self.string, self.font_path, self.layer), \ + (self.offset, self.height / norm_value, self.rotation, self.dose), \ + lambda: Text(string=self.string, + height=self.height * norm_value, + font_path=self.font_path, + 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 = [[+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 diff --git a/setup.py b/setup.py index 9a064ed..251edf7 100644 --- a/setup.py +++ b/setup.py @@ -16,6 +16,7 @@ setup(name='masque', 'visualization': ['matplotlib'], 'gdsii': ['python-gdsii'], 'svg': ['svgwrite'], + 'text': ['freetype-py', 'matplotlib'] }, )