#!/usr/bin/env python # encoding: utf8 # # Sync glyph shapes between SVG and UFO, creating a bridge between UFO and Figma. # import os import sys import argparse import re from StringIO import StringIO from hashlib import sha256 from xml.dom.minidom import parseString as xmlparseString from svgpathtools import svg2paths, parse_path, Path, Line, CubicBezier from base64 import b64encode # from robofab.world import world, RFont, RGlyph, OpenFont, NewFont from robofab.objects.objectsRF import RFont, RGlyph, OpenFont, NewFont, RContour from robofab.objects.objectsBase import MOVE, LINE, CORNER, CURVE, QCURVE, OFFCURVE font = None # RFont ufopath = '' svgdir = '' effectiveAscender = 0 def num(s): return int(s) if s.find('.') == -1 else float(s) def glyphToSVGPath(g, yMul=-1): commands = {'move':'M','line':'L','curve':'Y','offcurve':'X','offCurve':'X'} svg = '' contours = [] if len(g.components): font.newGlyph('__svgsync') new = font['__svgsync'] new.width = g.width new.appendGlyph(g) new.decompose() g = new if len(g): for c in range(len(g)): contours.append(g[c]) for i in range(len(contours)): c = contours[i] contour = end = '' curve = False points = c.points if points[0].type == 'offCurve': points.append(points.pop(0)) if points[0].type == 'offCurve': points.append(points.pop(0)) for x in range(len(points)): p = points[x] command = commands[str(p.type)] if command == 'X': if curve == True: command = '' else: command = 'C' curve = True if command == 'Y': command = '' curve = False if x == 0: command = 'M' if p.type == 'curve': end = ' ' + str(p.x) + ' ' + str(p.y * yMul) contour += ' ' + command + str(p.x) + ' ' + str(p.y * yMul) svg += ' ' + contour + end + 'z' if font.has_key('__svgsync'): font.removeGlyph('__svgsync') return svg.strip() def vec2(x, y): return float(x) + float(y) * 1j def glyphToPaths(g, yMul=-1): paths = [] contours = [] yOffs = -font.info.unitsPerEm # decompose components if len(g.components): font.newGlyph('__svgsync') ng = font['__svgsync'] ng.width = g.width ng.appendGlyph(g) ng.decompose() g = ng for c in g: curve = False points = c.points path = Path() currentPos = 0j controlPoints = [] for x in range(len(points)): p = points[x] # print 'p#' + str(x) + '.type = ' + repr(p.type) if p.type == 'move': currentPos = vec2(p.x, (p.y + yOffs) * yMul) elif p.type == 'offcurve': controlPoints.append(p) elif p.type == 'curve': pos = vec2(p.x, (p.y + yOffs) * yMul) if len(controlPoints) == 2: cp1, cp2 = controlPoints path.append(CubicBezier( currentPos, vec2(cp1.x, (cp1.y + yOffs) * yMul), vec2(cp2.x, (cp2.y + yOffs) * yMul), pos)) else: if len(controlPoints) != 1: raise Exception('unexpected number of control points for curve') cp = controlPoints[0] path.append(QuadraticBezier(currentPos, vec2(cp.x, (cp.y + yOffs) * yMul), pos)) currentPos = pos controlPoints = [] elif p.type == 'line': pos = vec2(p.x, (p.y + yOffs) * yMul) path.append(Line(currentPos, pos)) currentPos = pos paths.append(path) if font.has_key('__svgsync'): font.removeGlyph('__svgsync') return paths def maybeAddMove(contour, x, y, smooth): if len(contour.segments) == 0: contour.appendSegment(MOVE, [(x, y)], smooth=smooth) svgPathDataRegEx = re.compile(r'(?:([A-Z])\s*|)([0-9\.\-\+eE]+)') def drawSVGPath(g, d, tr): yMul = -1 xOffs = tr[0] yOffs = -(font.info.unitsPerEm - tr[1]) for pathd in d.split('M'): pathd = pathd.strip() # print 'pathd', pathd if len(pathd) == 0: continue i = 0 closePath = False if pathd[-1] == 'z': closePath = True pathd = pathd[0:-1] pv = [] for m in svgPathDataRegEx.finditer('M' + pathd): if m.group(1) is not None: pv.append(m.group(1) + m.group(2)) else: pv.append(m.group(2)) initX = 0 initY = 0 pen = g.getPen() while i < len(pv): pd = pv[i]; i += 1 cmd = pd[0] x = num(pd[1:]) + xOffs y = (num(pv[i]) + yOffs) * yMul; i += 1 if cmd == 'M': # print cmd, x, y, '/', num(pv[i-2][1:]) initX = x initY = y pen.moveTo((x, y)) continue if cmd == 'C': # Bezier curve: "C x1 y1, x2 y2, x y" x1 = x y1 = y x2 = num(pv[i]) + xOffs; i += 1 y2 = (num(pv[i]) + yOffs) * yMul; i += 1 x = num(pv[i]) + xOffs; i += 1 y = (num(pv[i]) + yOffs) * yMul; i += 1 pen.curveTo((x1, y1), (x2, y2), (x, y)) # print cmd, x1, y1, x2, y2, x, y elif cmd == 'L': pen.lineTo((x, y)) else: raise Exception('unexpected SVG path command %r' % cmd) if closePath: pen.closePath() else: pen.endPath() # print 'path ended. closePath:', closePath def glyphToSVG(g, path, hash): width = g.width height = font.info.unitsPerEm d = { 'name': g.name, 'width': width, 'height': effectiveAscender - font.info.descender, 'effectiveAscender': effectiveAscender, 'leftMargin': g.leftMargin, 'rightMargin': g.rightMargin, 'd': path.d(use_closed_attrib=True), 'ascender': font.info.ascender, 'descender': font.info.descender, 'baselineOffset': height + font.info.descender, 'unitsPerEm': font.info.unitsPerEm, 'hash': hash, } svg = ''' ''' % d # print svg return svg.strip() def _findPathNodes(n, paths, defs, uses, isDef=False): for cn in n.childNodes: if cn.nodeName == 'path': if isDef: defs[cn.getAttribute('id')] = cn else: paths.append(cn) elif cn.nodeName == 'use': uses[cn.getAttribute('xlink:href').lstrip('#')] = {'useNode': cn, 'targetNode': None} elif cn.nodeName == 'defs': _findPathNodes(cn, paths, defs, uses, isDef=True) elif not isinstance(cn, basestring) and cn.childNodes and len(cn.childNodes) > 0: _findPathNodes(cn, paths, defs, uses, isDef) # return translate def findPathNodes(n, isDef=False): paths = [] defs = {} uses = {} # # # # # # ... # # 0: m = re.match(r"translate\s*\(\s*(?P[\-\d\.eE]+)[\s,]*(?P[\-\d\.eE]+)\s*\)", tr) if m is not None: x += num(m.group('x')) y += num(m.group('y')) else: raise Exception('Unable to handle transform="%s"' % tr) # m = re.match(r"matrix\s*\(\s*(?P[\-\d\.eE]+)[\s,]*(?P[\-\d\.eE]+)[\s,]*(?P[\-\d\.eE]+)[\s,]*(?P[\-\d\.eE]+)[\s,]*(?P[\-\d\.eE]+)[\s,]*(?P[\-\d\.eE]+)[\s,]*", tr) # if m is not None: # a, b, c = num(m.group('a')), num(m.group('b')), num(m.group('c')) # d, e, f = num(m.group('d')), num(m.group('e')), num(m.group('f')) # # matrix -1 0 0 -1 -660.719 31947 # print 'matrix', a, b, c, d, e, f # # matrix(-1 0 -0 -1 -2553 31943) pn = path.parentNode if pn is not None and pn.nodeName != '#document': x, y = nodeTranslation(pn, x, y) return (x, y) def glyphUpdateFromSVG(g, svgCode): doc = xmlparseString(svgCode) svg = doc.documentElement paths = findPathNodes(svg) if len(paths) == 0: raise Exception('no found in SVG') path = paths[0] if len(paths) != 1: for p in paths: id = p.getAttribute('id') if id is not None and id.find('stroke') == -1: path = p break tr = nodeTranslation(path) d = path.getAttribute('d') g.clearContours() drawSVGPath(g, d, tr) def stat(path): try: return os.stat(path) except OSError as e: return None def writeFile(file, s): with open(file, 'w') as f: f.write(s) def writeFileAndMkDirsIfNeeded(file, s): try: writeFile(file, s) except IOError as e: if e.errno == 2: os.makedirs(os.path.dirname(file)) writeFile(file, s) def findSvgSyncHashInSVG(svgCode): # with open(svgFile, 'r') as f: # svgCode = f.readline(512) r = re.compile(r'^\s*]+data-svgsync-hash="([^"]*)".+') m = r.match(svgCode) if m is not None: return m.group(1) return None def computeSVGHashFromSVG(g): # h = sha256() return 'abc123' def encodePath(o, path): o.write(path.d()) def hashPaths(paths): h = sha256() for path in paths: h.update(path.d()+';') return b64encode(h.digest(), '-_') def svgGetPaths(svgCode): doc = xmlparseString(svgCode) svg = doc.documentElement paths = findPathNodes(svg) isFigmaSVG = svgCode.find('Figma') != -1 if len(paths) == 0: return paths, (0,0) paths2 = [] for path in paths: id = path.getAttribute('id') if not isFigmaSVG or (id is None or id.find('stroke') == -1): tr = nodeTranslation(path) d = path.getAttribute('d') paths2.append((d, tr)) return paths2, isFigmaSVG def translatePath(path, trX, trY): pass def parseSVG(svgFile): svgCode = None with open(svgFile, 'r') as f: svgCode = f.read() existingSvgHash = findSvgSyncHashInSVG(svgCode) print 'hash in SVG file:', existingSvgHash svgPathDefs, isFigmaSVG = svgGetPaths(svgCode) paths = [] for pathDef, tr in svgPathDefs: print 'pathDef:', pathDef, 'tr:', tr path = parse_path(pathDef) if tr[0] != 0 or tr[1] != 0: path = path.translated(vec2(*tr)) paths.append(path) return paths, existingSvgHash def syncGlyphUFOToSVG(g, glyphFile, svgFile, mtime, hasSvgFile): # # Let's print out the first path object and the color it was in the SVG # # We'll see it is composed of two CubicBezier objects and, in the SVG file it # # came from, it was red # paths, attributes, svg_attributes = svg2paths(svgFile, return_svg_attributes=True) # print('svg_attributes:', repr(svg_attributes)) # # redpath = paths[0] # # redpath_attribs = attributes[0] # print(paths) # print(attributes) # wsvg(paths, attributes=attributes, svg_attributes=svg_attributes, filename=svgFile + '-x.svg') # existingSVGHash = readSVGHash(svgFile) svgPaths = None existingSVGHash = None if hasSvgFile: svgPaths, existingSVGHash = parseSVG(svgFile) print 'existingSVGHash:', existingSVGHash print 'svgPaths:\n', '\n'.join([p.d() for p in svgPaths]) svgHash = hashPaths(svgPaths) print 'hash(SVG-glyph) =>', svgHash # computedSVGHash = computeSVGHashFromSVG(svgFile) # print 'computeSVGHashFromSVG:', computedSVGHash ufoPaths = glyphToPaths(g) print 'ufoPaths:\n', '\n'.join([p.d() for p in ufoPaths]) ufoGlyphHash = hashPaths(ufoPaths) print 'hash(UFO-glyph) =>', ufoGlyphHash # svg = glyphToSVG(g, ufoGlyphHash) # with open('/Users/rsms/src/interface/_local/svgPaths.txt', 'w') as f: # f.write(svgPaths[0].d()) # with open('/Users/rsms/src/interface/_local/ufoPaths.txt', 'w') as f: # f.write(ufoPaths[0].d()) # print svgPaths[0].d() == ufoPaths[0].d() # svgHash = hashPaths() # print 'hash(UFO-glyph) =>', pathHash sys.exit(1) if pathHash == existingSVGHash: return (None, 0) # did not change svg = glyphToSVG(g, pathHash) sys.exit(1) writeFileAndMkDirsIfNeeded(svgFile, svg) os.utime(svgFile, (mtime, mtime)) print 'svgsync write', svgFile g.lib['svgsync.hash'] = pathHash return (glyphFile, mtime) def syncGlyphSVGToUFO(glyphname, svgFile): print glyphname + ': SVG -> UFO' sys.exit(1) svg = '' with open(svgFile, 'r') as f: svg = f.read() g = font.getGlyph(glyphname) glyphUpdateFromSVG(g, svg) def findGlifFile(glyphname): # glyphname.glif # glyphname_.glif # glyphname__.glif # glyphname___.glif for underscoreCount in range(0, 5): fn = os.path.join(ufopath, 'glyphs', glyphname + ('_' * underscoreCount) + '.glif') st = stat(fn) if st is not None: return fn, st if glyphname.find('.') != -1: # glyph_.name.glif # glyph__.name.glif # glyph___.name.glif for underscoreCount in range(0, 5): nv = glyphname.split('.') nv[0] = nv[0] + ('_' * underscoreCount) ns = '.'.join(nv) fn = os.path.join(ufopath, 'glyphs', ns + '.glif') st = stat(fn) if st is not None: return fn, st if glyphname.find('_') != -1: # glyph_name.glif # glyph_name_.glif # glyph_name__.glif # glyph__name.glif # glyph__name_.glif # glyph__name__.glif # glyph___name.glif # glyph___name_.glif # glyph___name__.glif for x in range(0, 4): for y in range(0, 5): ns = glyphname.replace('_', '__' + ('_' * x)) fn = os.path.join(ufopath, 'glyphs', ns + ('_' * y) + '.glif') st = stat(fn) if st is not None: return fn, st return ('', None) def syncGlyph(glyphname, createSVG=False): # => (glyphname, mtime) or (None, 0) if noop glyphFile, glyphStat = findGlifFile(glyphname) svgFile = os.path.join(svgdir, glyphname + '.svg') svgStat = stat(svgFile) if glyphStat is None and svgStat is None: raise Exception("glyph %r doesn't exist in UFO or SVG directory" % glyphname) c = cmp( 0 if glyphStat is None else glyphStat.st_mtime, 0 if svgStat is None else svgStat.st_mtime ) g = font.getGlyph(glyphname) ufoPathHash = g.lib['svgsync.hash'] if 'svgsync.hash' in g.lib else None print '[syncGlyph] g.lib["svgsync.hash"] =', ufoPathHash c = 1 # XXX DEBUG if c < 0: syncGlyphSVGToUFO(glyphname, svgFile) return (glyphFile, svgStat.st_mtime) # glif file in UFO change + it's new mtime elif c > 0 and (svgStat is not None or createSVG): print glyphname + ': UFO -> SVG' return syncGlyphUFOToSVG( g, glyphFile, svgFile, glyphStat.st_mtime, hasSvgFile=svgStat is not None ) return (None, 0) # UFO did not change # ———————————————————————————————————————————————————————————————————————— # main argparser = argparse.ArgumentParser(description='Convert UFO glyphs to SVG') argparser.add_argument('--svgdir', dest='svgdir', metavar='', type=str, default='', help='Write SVG files to . If not specified, SVG files are' + ' written to: {dirname()/svg//