from array import array from typing import Any, Callable, Dict, Optional, Tuple from fontTools.misc.fixedTools import MAX_F2DOT14, floatToFixedToFloat from fontTools.misc.loggingTools import LogMixin from fontTools.pens.pointPen import AbstractPointPen from fontTools.misc.roundTools import otRound from fontTools.pens.basePen import LoggingPen, PenError from fontTools.pens.transformPen import TransformPen, TransformPointPen from fontTools.ttLib.tables import ttProgram from fontTools.ttLib.tables._g_l_y_f import flagOnCurve, flagCubic from fontTools.ttLib.tables._g_l_y_f import Glyph from fontTools.ttLib.tables._g_l_y_f import GlyphComponent from fontTools.ttLib.tables._g_l_y_f import GlyphCoordinates from fontTools.ttLib.tables._g_l_y_f import dropImpliedOnCurvePoints import math __all__ = ["TTGlyphPen", "TTGlyphPointPen"] class _TTGlyphBasePen: def __init__( self, glyphSet: Optional[Dict[str, Any]], handleOverflowingTransforms: bool = True, ) -> None: """ Construct a new pen. Args: glyphSet (Dict[str, Any]): A glyphset object, used to resolve components. handleOverflowingTransforms (bool): See below. If ``handleOverflowingTransforms`` is True, the components' transform values are checked that they don't overflow the limits of a F2Dot14 number: -2.0 <= v < +2.0. If any transform value exceeds these, the composite glyph is decomposed. An exception to this rule is done for values that are very close to +2.0 (both for consistency with the -2.0 case, and for the relative frequency these occur in real fonts). When almost +2.0 values occur (and all other values are within the range -2.0 <= x <= +2.0), they are clamped to the maximum positive value that can still be encoded as an F2Dot14: i.e. 1.99993896484375. If False, no check is done and all components are translated unmodified into the glyf table, followed by an inevitable ``struct.error`` once an attempt is made to compile them. If both contours and components are present in a glyph, the components are decomposed. """ self.glyphSet = glyphSet self.handleOverflowingTransforms = handleOverflowingTransforms self.init() def _decompose( self, glyphName: str, transformation: Tuple[float, float, float, float, float, float], ): tpen = self.transformPen(self, transformation) getattr(self.glyphSet[glyphName], self.drawMethod)(tpen) def _isClosed(self): """ Check if the current path is closed. """ raise NotImplementedError def init(self) -> None: self.points = [] self.endPts = [] self.types = [] self.components = [] def addComponent( self, baseGlyphName: str, transformation: Tuple[float, float, float, float, float, float], identifier: Optional[str] = None, **kwargs: Any, ) -> None: """ Add a sub glyph. """ self.components.append((baseGlyphName, transformation)) def _buildComponents(self, componentFlags): if self.handleOverflowingTransforms: # we can't encode transform values > 2 or < -2 in F2Dot14, # so we must decompose the glyph if any transform exceeds these overflowing = any( s > 2 or s < -2 for (glyphName, transformation) in self.components for s in transformation[:4] ) components = [] for glyphName, transformation in self.components: if glyphName not in self.glyphSet: self.log.warning(f"skipped non-existing component '{glyphName}'") continue if self.points or (self.handleOverflowingTransforms and overflowing): # can't have both coordinates and components, so decompose self._decompose(glyphName, transformation) continue component = GlyphComponent() component.glyphName = glyphName component.x, component.y = (otRound(v) for v in transformation[4:]) # quantize floats to F2Dot14 so we get same values as when decompiled # from a binary glyf table transformation = tuple( floatToFixedToFloat(v, 14) for v in transformation[:4] ) if transformation != (1, 0, 0, 1): if self.handleOverflowingTransforms and any( MAX_F2DOT14 < s <= 2 for s in transformation ): # clamp values ~= +2.0 so we can keep the component transformation = tuple( MAX_F2DOT14 if MAX_F2DOT14 < s <= 2 else s for s in transformation ) component.transform = (transformation[:2], transformation[2:]) component.flags = componentFlags components.append(component) return components def glyph( self, componentFlags: int = 0x04, dropImpliedOnCurves: bool = False, *, round: Callable[[float], int] = otRound, ) -> Glyph: """ Returns a :py:class:`~._g_l_y_f.Glyph` object representing the glyph. Args: componentFlags: Flags to use for component glyphs. (default: 0x04) dropImpliedOnCurves: Whether to remove implied-oncurve points. (default: False) """ if not self._isClosed(): raise PenError("Didn't close last contour.") components = self._buildComponents(componentFlags) glyph = Glyph() glyph.coordinates = GlyphCoordinates(self.points) glyph.endPtsOfContours = self.endPts glyph.flags = array("B", self.types) self.init() if components: # If both components and contours were present, they have by now # been decomposed by _buildComponents. glyph.components = components glyph.numberOfContours = -1 else: glyph.numberOfContours = len(glyph.endPtsOfContours) glyph.program = ttProgram.Program() glyph.program.fromBytecode(b"") if dropImpliedOnCurves: dropImpliedOnCurvePoints(glyph) glyph.coordinates.toInt(round=round) return glyph class TTGlyphPen(_TTGlyphBasePen, LoggingPen): """ Pen used for drawing to a TrueType glyph. This pen can be used to construct or modify glyphs in a TrueType format font. After using the pen to draw, use the ``.glyph()`` method to retrieve a :py:class:`~._g_l_y_f.Glyph` object representing the glyph. """ drawMethod = "draw" transformPen = TransformPen def __init__( self, glyphSet: Optional[Dict[str, Any]] = None, handleOverflowingTransforms: bool = True, outputImpliedClosingLine: bool = False, ) -> None: super().__init__(glyphSet, handleOverflowingTransforms) self.outputImpliedClosingLine = outputImpliedClosingLine def _addPoint(self, pt: Tuple[float, float], tp: int) -> None: self.points.append(pt) self.types.append(tp) def _popPoint(self) -> None: self.points.pop() self.types.pop() def _isClosed(self) -> bool: return (not self.points) or ( self.endPts and self.endPts[-1] == len(self.points) - 1 ) def lineTo(self, pt: Tuple[float, float]) -> None: self._addPoint(pt, flagOnCurve) def moveTo(self, pt: Tuple[float, float]) -> None: if not self._isClosed(): raise PenError('"move"-type point must begin a new contour.') self._addPoint(pt, flagOnCurve) def curveTo(self, *points) -> None: assert len(points) % 2 == 1 for pt in points[:-1]: self._addPoint(pt, flagCubic) # last point is None if there are no on-curve points if points[-1] is not None: self._addPoint(points[-1], 1) def qCurveTo(self, *points) -> None: assert len(points) >= 1 for pt in points[:-1]: self._addPoint(pt, 0) # last point is None if there are no on-curve points if points[-1] is not None: self._addPoint(points[-1], 1) def closePath(self) -> None: endPt = len(self.points) - 1 # ignore anchors (one-point paths) if endPt == 0 or (self.endPts and endPt == self.endPts[-1] + 1): self._popPoint() return if not self.outputImpliedClosingLine: # if first and last point on this path are the same, remove last startPt = 0 if self.endPts: startPt = self.endPts[-1] + 1 if self.points[startPt] == self.points[endPt]: self._popPoint() endPt -= 1 self.endPts.append(endPt) def endPath(self) -> None: # TrueType contours are always "closed" self.closePath() class TTGlyphPointPen(_TTGlyphBasePen, LogMixin, AbstractPointPen): """ Point pen used for drawing to a TrueType glyph. This pen can be used to construct or modify glyphs in a TrueType format font. After using the pen to draw, use the ``.glyph()`` method to retrieve a :py:class:`~._g_l_y_f.Glyph` object representing the glyph. """ drawMethod = "drawPoints" transformPen = TransformPointPen def init(self) -> None: super().init() self._currentContourStartIndex = None def _isClosed(self) -> bool: return self._currentContourStartIndex is None def beginPath(self, identifier: Optional[str] = None, **kwargs: Any) -> None: """ Start a new sub path. """ if not self._isClosed(): raise PenError("Didn't close previous contour.") self._currentContourStartIndex = len(self.points) def endPath(self) -> None: """ End the current sub path. """ # TrueType contours are always "closed" if self._isClosed(): raise PenError("Contour is already closed.") if self._currentContourStartIndex == len(self.points): # ignore empty contours self._currentContourStartIndex = None return contourStart = self.endPts[-1] + 1 if self.endPts else 0 self.endPts.append(len(self.points) - 1) self._currentContourStartIndex = None # Resolve types for any cubic segments flags = self.types for i in range(contourStart, len(flags)): if flags[i] == "curve": j = i - 1 if j < contourStart: j = len(flags) - 1 while flags[j] == 0: flags[j] = flagCubic j -= 1 flags[i] = flagOnCurve def addPoint( self, pt: Tuple[float, float], segmentType: Optional[str] = None, smooth: bool = False, name: Optional[str] = None, identifier: Optional[str] = None, **kwargs: Any, ) -> None: """ Add a point to the current sub path. """ if self._isClosed(): raise PenError("Can't add a point to a closed contour.") if segmentType is None: self.types.append(0) elif segmentType in ("line", "move"): self.types.append(flagOnCurve) elif segmentType == "qcurve": self.types.append(flagOnCurve) elif segmentType == "curve": self.types.append("curve") else: raise AssertionError(segmentType) self.points.append(pt)