#!/usr/bin/env python3 from __future__ import unicode_literals '''Expands an unclosed UFO stroke font into monoline forms with a fixed width''' __url__ = 'https://github.com/silnrsi/pysilfont' __copyright__ = 'Copyright (c) 2017 SIL International (https://www.sil.org), based on outlinerRoboFontExtension Copyright (c) 2016 Frederik Berlaen' __license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' __author__ = 'Victor Gaultney' # Usage: psfexpandstroke ifont ofont expansion # expansion is the number of units added to each side of the stroke # To Do # - Simplify to assume round caps and corners # main input, output, and execution handled by pysilfont framework from silfont.core import execute from fontTools.pens.basePen import BasePen from fontTools.misc.bezierTools import splitCubicAtT from robofab.world import OpenFont from robofab.pens.pointPen import AbstractPointPen from robofab.pens.reverseContourPointPen import ReverseContourPointPen from robofab.pens.adapterPens import PointToSegmentPen from defcon import Glyph from math import sqrt, cos, sin, acos, asin, degrees, radians, pi suffix = '_expanded' argspec = [ ('ifont',{'help': 'Input font file'}, {'type': 'filename'}), ('ofont',{'help': 'Output font file','nargs': '?' }, {'type': 'filename', 'def': "_"+suffix}), ('thickness',{'help': 'Stroke thickness'}, {}), ('-l','--log',{'help': 'Log file'}, {'type': 'outfile', 'def': suffix+'.log'})] # The following functions are straight from outlinerRoboFontExtension def roundFloat(f): error = 1000000. return round(f*error)/error def checkSmooth(firstAngle, lastAngle): if firstAngle is None or lastAngle is None: return True error = 4 firstAngle = degrees(firstAngle) lastAngle = degrees(lastAngle) if int(firstAngle) + error >= int(lastAngle) >= int(firstAngle) - error: return True return False def checkInnerOuter(firstAngle, lastAngle): if firstAngle is None or lastAngle is None: return True dirAngle = degrees(firstAngle) - degrees(lastAngle) if dirAngle > 180: dirAngle = 180 - dirAngle elif dirAngle < -180: dirAngle = -180 - dirAngle if dirAngle > 0: return True if dirAngle <= 0: return False def interSect((seg1s, seg1e), (seg2s, seg2e)): denom = (seg2e.y - seg2s.y)*(seg1e.x - seg1s.x) - (seg2e.x - seg2s.x)*(seg1e.y - seg1s.y) if roundFloat(denom) == 0: # print 'parallel: %s' % denom return None uanum = (seg2e.x - seg2s.x)*(seg1s.y - seg2s.y) - (seg2e.y - seg2s.y)*(seg1s.x - seg2s.x) ubnum = (seg1e.x - seg1s.x)*(seg1s.y - seg2s.y) - (seg1e.y - seg1s.y)*(seg1s.x - seg2s.x) ua = uanum / denom # ub = ubnum / denom x = seg1s.x + ua*(seg1e.x - seg1s.x) y = seg1s.y + ua*(seg1e.y - seg1s.y) return MathPoint(x, y) def pointOnACurve((x1, y1), (cx1, cy1), (cx2, cy2), (x2, y2), value): dx = x1 cx = (cx1 - dx) * 3.0 bx = (cx2 - cx1) * 3.0 - cx ax = x2 - dx - cx - bx dy = y1 cy = (cy1 - dy) * 3.0 by = (cy2 - cy1) * 3.0 - cy ay = y2 - dy - cy - by mx = ax*(value)**3 + bx*(value)**2 + cx*(value) + dx my = ay*(value)**3 + by*(value)**2 + cy*(value) + dy return MathPoint(mx, my) class MathPoint(object): def __init__(self, x, y=None): if y is None: x, y = x self.x = x self.y = y def __repr__(self): return "" % (self.x, self.y) def __getitem__(self, index): if index == 0: return self.x if index == 1: return self.y raise IndexError def __iter__(self): for value in [self.x, self.y]: yield value def __add__(self, p): # p+ p if not isinstance(p, self.__class__): return self.__class__(self.x + p, self.y + p) return self.__class__(self.x + p.x, self.y + p.y) def __sub__(self, p): # p - p if not isinstance(p, self.__class__): return self.__class__(self.x - p, self.y - p) return self.__class__(self.x - p.x, self.y - p.y) def __mul__(self, p): # p * p if not isinstance(p, self.__class__): return self.__class__(self.x * p, self.y * p) return self.__class__(self.x * p.x, self.y * p.y) def __div__(self, p): if not isinstance(p, self.__class__): return self.__class__(self.x / p, self.y / p) return self.__class__(self.x / p.x, self.y / p.y) def __eq__(self, p): # if p == p if not isinstance(p, self.__class__): return False return roundFloat(self.x) == roundFloat(p.x) and roundFloat(self.y) == roundFloat(p.y) def __ne__(self, p): # if p != p return not self.__eq__(p) def copy(self): return self.__class__(self.x, self.y) def round(self): self.x = round(self.x) self.y = round(self.y) def distance(self, p): return sqrt((p.x - self.x)**2 + (p.y - self.y)**2) def angle(self, other, add=90): # returns the angle of a Line in radians b = other.x - self.x a = other.y - self.y c = sqrt(a**2 + b**2) if c == 0: return None if add is None: return b/c cosAngle = degrees(acos(b/c)) sinAngle = degrees(asin(a/c)) if sinAngle < 0: cosAngle = 360 - cosAngle return radians(cosAngle + add) class CleanPointPen(AbstractPointPen): def __init__(self, pointPen): self.pointPen = pointPen self.currentContour = None def processContour(self): pointPen = self.pointPen contour = self.currentContour index = 0 prevAngle = None toRemove = [] for data in contour: if data["segmentType"] in ["line", "move"]: prevPoint = contour[index-1] if prevPoint["segmentType"] in ["line", "move"]: angle = MathPoint(data["point"]).angle(MathPoint(prevPoint["point"])) if prevAngle is not None and angle is not None and roundFloat(prevAngle) == roundFloat(angle): prevPoint["uniqueID"] = id(prevPoint) toRemove.append(prevPoint) prevAngle = angle else: prevAngle = None else: prevAngle = None index += 1 for data in toRemove: contour.remove(data) pointPen.beginPath() for data in contour: pointPen.addPoint(data["point"], **data) pointPen.endPath() def beginPath(self): assert self.currentContour is None self.currentContour = [] self.onCurve = [] def endPath(self): assert self.currentContour is not None self.processContour() self.currentContour = None def addPoint(self, pt, segmentType=None, smooth=False, name=None, **kwargs): data = dict(point=pt, segmentType=segmentType, smooth=smooth, name=name) data.update(kwargs) self.currentContour.append(data) def addComponent(self, glyphName, transform): assert self.currentContour is None self.pointPen.addComponent(glyphName, transform) # The following class has been been adjusted to work around how outline types use closePath() and endPath(), # to remove unneeded bits, and hard-code some assumptions. class OutlinePen(BasePen): pointClass = MathPoint magicCurve = 0.5522847498 def __init__(self, glyphSet, offset=10, contrast=0, contrastAngle=0, connection="round", cap="round", miterLimit=None, optimizeCurve=True): BasePen.__init__(self, glyphSet) self.offset = abs(offset) self.contrast = abs(contrast) self.contrastAngle = contrastAngle self._inputmiterLimit = miterLimit if miterLimit is None: miterLimit = self.offset * 2 self.miterLimit = abs(miterLimit) self.optimizeCurve = optimizeCurve self.connectionCallback = getattr(self, "connection%s" % (connection.title())) self.capCallback = getattr(self, "cap%s" % (cap.title())) self.originalGlyph = Glyph() self.originalPen = self.originalGlyph.getPen() self.outerGlyph = Glyph() self.outerPen = self.outerGlyph.getPen() self.outerCurrentPoint = None self.outerFirstPoint = None self.outerPrevPoint = None self.innerGlyph = Glyph() self.innerPen = self.innerGlyph.getPen() self.innerCurrentPoint = None self.innerFirstPoint = None self.innerPrevPoint = None self.prevPoint = None self.firstPoint = None self.firstAngle = None self.prevAngle = None self.shouldHandleMove = True self.components = [] self.drawSettings() def _moveTo(self, (x, y)): if self.offset == 0: self.outerPen.moveTo((x, y)) self.innerPen.moveTo((x, y)) return self.originalPen.moveTo((x, y)) p = self.pointClass(x, y) self.prevPoint = p self.firstPoint = p self.shouldHandleMove = True def _lineTo(self, (x, y)): if self.offset == 0: self.outerPen.lineTo((x, y)) self.innerPen.lineTo((x, y)) return self.originalPen.lineTo((x, y)) currentPoint = self.pointClass(x, y) if currentPoint == self.prevPoint: return self.currentAngle = self.prevPoint.angle(currentPoint) thickness = self.getThickness(self.currentAngle) self.innerCurrentPoint = self.prevPoint - self.pointClass(cos(self.currentAngle), sin(self.currentAngle)) * thickness self.outerCurrentPoint = self.prevPoint + self.pointClass(cos(self.currentAngle), sin(self.currentAngle)) * thickness if self.shouldHandleMove: self.shouldHandleMove = False self.innerPen.moveTo(self.innerCurrentPoint) self.innerFirstPoint = self.innerCurrentPoint self.outerPen.moveTo(self.outerCurrentPoint) self.outerFirstPoint = self.outerCurrentPoint self.firstAngle = self.currentAngle else: self.buildConnection() self.innerCurrentPoint = currentPoint - self.pointClass(cos(self.currentAngle), sin(self.currentAngle)) * thickness self.innerPen.lineTo(self.innerCurrentPoint) self.innerPrevPoint = self.innerCurrentPoint self.outerCurrentPoint = currentPoint + self.pointClass(cos(self.currentAngle), sin(self.currentAngle)) * thickness self.outerPen.lineTo(self.outerCurrentPoint) self.outerPrevPoint = self.outerCurrentPoint self.prevPoint = currentPoint self.prevAngle = self.currentAngle def _curveToOne(self, (x1, y1), (x2, y2), (x3, y3)): if self.optimizeCurve: curves = splitCubicAtT(self.prevPoint, (x1, y1), (x2, y2), (x3, y3), .5) else: curves = [(self.prevPoint, (x1, y1), (x2, y2), (x3, y3))] for curve in curves: p1, h1, h2, p2 = curve self._processCurveToOne(h1, h2, p2) def _processCurveToOne(self, (x1, y1), (x2, y2), (x3, y3)): if self.offset == 0: self.outerPen.curveTo((x1, y1), (x2, y2), (x3, y3)) self.innerPen.curveTo((x1, y1), (x2, y2), (x3, y3)) return self.originalPen.curveTo((x1, y1), (x2, y2), (x3, y3)) p1 = self.pointClass(x1, y1) p2 = self.pointClass(x2, y2) p3 = self.pointClass(x3, y3) if p1 == self.prevPoint: p1 = pointOnACurve(self.prevPoint, p1, p2, p3, 0.01) if p2 == p3: p2 = pointOnACurve(self.prevPoint, p1, p2, p3, 0.99) a1 = self.prevPoint.angle(p1) a2 = p2.angle(p3) self.currentAngle = a1 tickness1 = self.getThickness(a1) tickness2 = self.getThickness(a2) a1bis = self.prevPoint.angle(p1, 0) a2bis = p3.angle(p2, 0) intersectPoint = interSect((self.prevPoint, self.prevPoint + self.pointClass(cos(a1), sin(a1)) * 100), (p3, p3 + self.pointClass(cos(a2), sin(a2)) * 100)) self.innerCurrentPoint = self.prevPoint - self.pointClass(cos(a1), sin(a1)) * tickness1 self.outerCurrentPoint = self.prevPoint + self.pointClass(cos(a1), sin(a1)) * tickness1 if self.shouldHandleMove: self.shouldHandleMove = False self.innerPen.moveTo(self.innerCurrentPoint) self.innerFirstPoint = self.innerPrevPoint = self.innerCurrentPoint self.outerPen.moveTo(self.outerCurrentPoint) self.outerFirstPoint = self.outerPrevPoint = self.outerCurrentPoint self.firstAngle = a1 else: self.buildConnection() h1 = None if intersectPoint is not None: h1 = interSect((self.innerCurrentPoint, self.innerCurrentPoint + self.pointClass(cos(a1bis), sin(a1bis)) * tickness1), (intersectPoint, p1)) if h1 is None: h1 = p1 - self.pointClass(cos(a1), sin(a1)) * tickness1 self.innerCurrentPoint = p3 - self.pointClass(cos(a2), sin(a2)) * tickness2 h2 = None if intersectPoint is not None: h2 = interSect((self.innerCurrentPoint, self.innerCurrentPoint + self.pointClass(cos(a2bis), sin(a2bis)) * tickness2), (intersectPoint, p2)) if h2 is None: h2 = p2 - self.pointClass(cos(a1), sin(a1)) * tickness1 self.innerPen.curveTo(h1, h2, self.innerCurrentPoint) self.innerPrevPoint = self.innerCurrentPoint ######## h1 = None if intersectPoint is not None: h1 = interSect((self.outerCurrentPoint, self.outerCurrentPoint + self.pointClass(cos(a1bis), sin(a1bis)) * tickness1), (intersectPoint, p1)) if h1 is None: h1 = p1 + self.pointClass(cos(a1), sin(a1)) * tickness1 self.outerCurrentPoint = p3 + self.pointClass(cos(a2), sin(a2)) * tickness2 h2 = None if intersectPoint is not None: h2 = interSect((self.outerCurrentPoint, self.outerCurrentPoint + self.pointClass(cos(a2bis), sin(a2bis)) * tickness2), (intersectPoint, p2)) if h2 is None: h2 = p2 + self.pointClass(cos(a1), sin(a1)) * tickness1 self.outerPen.curveTo(h1, h2, self.outerCurrentPoint) self.outerPrevPoint = self.outerCurrentPoint self.prevPoint = p3 self.currentAngle = a2 self.prevAngle = a2 def _closePath(self): if self.shouldHandleMove: return self.originalPen.endPath() self.innerPen.endPath() self.outerPen.endPath() innerContour = self.innerGlyph[-1] outerContour = self.outerGlyph[-1] innerContour.reverse() innerContour[0].segmentType = "line" outerContour[0].segmentType = "line" self.buildCap(outerContour, innerContour) for point in innerContour: outerContour.addPoint((point.x, point.y), segmentType=point.segmentType, smooth=point.smooth) self.innerGlyph.removeContour(innerContour) def _endPath(self): # The current way glyph outlines are processed means that _endPath() would not be called # _closePath() is used instead pass def addComponent(self, glyphName, transform): self.components.append((glyphName, transform)) # thickness def getThickness(self, angle): a2 = angle + pi * .5 f = abs(sin(a2 + radians(self.contrastAngle))) f = f ** 5 return self.offset + self.contrast * f # connections def buildConnection(self, close=False): if not checkSmooth(self.prevAngle, self.currentAngle): if checkInnerOuter(self.prevAngle, self.currentAngle): self.connectionCallback(self.outerPrevPoint, self.outerCurrentPoint, self.outerPen, close) self.connectionInnerCorner(self.innerPrevPoint, self.innerCurrentPoint, self.innerPen, close) else: self.connectionCallback(self.innerPrevPoint, self.innerCurrentPoint, self.innerPen, close) self.connectionInnerCorner(self.outerPrevPoint, self.outerCurrentPoint, self.outerPen, close) def connectionRound(self, first, last, pen, close): angle_1 = radians(degrees(self.prevAngle)+90) angle_2 = radians(degrees(self.currentAngle)+90) tempFirst = first - self.pointClass(cos(angle_1), sin(angle_1)) * self.miterLimit tempLast = last + self.pointClass(cos(angle_2), sin(angle_2)) * self.miterLimit newPoint = interSect((first, tempFirst), (last, tempLast)) if newPoint is None: pen.lineTo(last) return distance1 = newPoint.distance(first) distance2 = newPoint.distance(last) if roundFloat(distance1) > self.miterLimit + self.contrast: distance1 = self.miterLimit + tempFirst.distance(tempLast) * .7 if roundFloat(distance2) > self.miterLimit + self.contrast: distance2 = self.miterLimit + tempFirst.distance(tempLast) * .7 distance1 *= self.magicCurve distance2 *= self.magicCurve bcp1 = first - self.pointClass(cos(angle_1), sin(angle_1)) * distance1 bcp2 = last + self.pointClass(cos(angle_2), sin(angle_2)) * distance2 pen.curveTo(bcp1, bcp2, last) def connectionInnerCorner(self, first, last, pen, close): if not close: pen.lineTo(last) # caps def buildCap(self, firstContour, lastContour): first = firstContour[-1] last = lastContour[0] first = self.pointClass(first.x, first.y) last = self.pointClass(last.x, last.y) self.capCallback(firstContour, lastContour, first, last, self.prevAngle) first = lastContour[-1] last = firstContour[0] first = self.pointClass(first.x, first.y) last = self.pointClass(last.x, last.y) angle = radians(degrees(self.firstAngle)+180) self.capCallback(lastContour, firstContour, first, last, angle) def capRound(self, firstContour, lastContour, first, last, angle): hookedAngle = radians(degrees(angle)+90) p1 = first - self.pointClass(cos(hookedAngle), sin(hookedAngle)) * self.offset p2 = last - self.pointClass(cos(hookedAngle), sin(hookedAngle)) * self.offset oncurve = p1 + (p2-p1)*.5 roundness = .54 h1 = first - self.pointClass(cos(hookedAngle), sin(hookedAngle)) * self.offset * roundness h2 = oncurve + self.pointClass(cos(angle), sin(angle)) * self.offset * roundness firstContour[-1].smooth = True firstContour.addPoint((h1.x, h1.y)) firstContour.addPoint((h2.x, h2.y)) firstContour.addPoint((oncurve.x, oncurve.y), smooth=True, segmentType="curve") h1 = oncurve - self.pointClass(cos(angle), sin(angle)) * self.offset * roundness h2 = last - self.pointClass(cos(hookedAngle), sin(hookedAngle)) * self.offset * roundness firstContour.addPoint((h1.x, h1.y)) firstContour.addPoint((h2.x, h2.y)) lastContour[0].segmentType = "curve" lastContour[0].smooth = True def drawSettings(self, drawOriginal=False, drawInner=False, drawOuter=True): self.drawOriginal = drawOriginal self.drawInner = drawInner self.drawOuter = drawOuter def drawPoints(self, pointPen): if self.drawInner: reversePen = ReverseContourPointPen(pointPen) self.innerGlyph.drawPoints(CleanPointPen(reversePen)) if self.drawOuter: self.outerGlyph.drawPoints(CleanPointPen(pointPen)) if self.drawOriginal: if self.drawOuter: pointPen = ReverseContourPointPen(pointPen) self.originalGlyph.drawPoints(CleanPointPen(pointPen)) for glyphName, transform in self.components: pointPen.addComponent(glyphName, transform) def draw(self, pen): pointPen = PointToSegmentPen(pen) self.drawPoints(pointPen) def getGlyph(self): glyph = Glyph() pointPen = glyph.getPointPen() self.drawPoints(pointPen) return glyph # The following functions have been decoupled from the outlinerRoboFontExtension and # effectively de-parameterized, with built-in assumptions def calculate(glyph, strokewidth): tickness = strokewidth contrast = 0 contrastAngle = 0 keepBounds = False optimizeCurve = True miterLimit = None #assumed corner = "round" #assumed - other options not supported cap = "round" #assumed - other options not supported drawOriginal = False drawInner = True drawOuter = True pen = OutlinePen(glyph.getParent(), tickness, contrast, contrastAngle, connection=corner, cap=cap, miterLimit=miterLimit, optimizeCurve=optimizeCurve) glyph.draw(pen) pen.drawSettings(drawOriginal=drawOriginal, drawInner=drawInner, drawOuter=drawOuter) result = pen.getGlyph() return result def expandGlyph(glyph, strokewidth): defconGlyph = glyph outline = calculate(defconGlyph, strokewidth) glyph.clearContours() outline.drawPoints(glyph.getPointPen()) glyph.round() def expandFont(targetfont, strokewidth): font = targetfont for glyph in font: expandGlyph(glyph, strokewidth) def doit(args): infont = OpenFont(args.ifont) outfont = args.ofont # add try to catch bad input strokewidth = int(args.thickness) expandFont(infont, strokewidth) infont.save(outfont) return infont def cmd() : execute(None,doit,argspec) if __name__ == "__main__": cmd()