diff options
Diffstat (limited to 'examples/psfexpandstroke.py')
-rwxr-xr-x | examples/psfexpandstroke.py | 641 |
1 files changed, 641 insertions, 0 deletions
diff --git a/examples/psfexpandstroke.py b/examples/psfexpandstroke.py new file mode 100755 index 0000000..54a340e --- /dev/null +++ b/examples/psfexpandstroke.py @@ -0,0 +1,641 @@ +#!/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 "<MathPoint x:%s y:%s>" % (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() |