From 3b1fffade1473f20f2558733fbd218f4580fc7c3 Mon Sep 17 00:00:00 2001 From: Rasmus Andersson Date: Tue, 22 Aug 2017 00:05:20 -0700 Subject: Initial public commit --- misc/pylib/fontbuild/Build.py | 300 +++++++++++++++++++++++ misc/pylib/fontbuild/LICENSE | 201 ++++++++++++++++ misc/pylib/fontbuild/ORIGIN.txt | 1 + misc/pylib/fontbuild/__init__.py | 6 + misc/pylib/fontbuild/alignpoints.py | 173 ++++++++++++++ misc/pylib/fontbuild/anchors.py | 77 ++++++ misc/pylib/fontbuild/convertCurves.py | 102 ++++++++ misc/pylib/fontbuild/curveFitPen.py | 422 +++++++++++++++++++++++++++++++++ misc/pylib/fontbuild/decomposeGlyph.py | 23 ++ misc/pylib/fontbuild/features.py | 189 +++++++++++++++ misc/pylib/fontbuild/generateGlyph.py | 97 ++++++++ misc/pylib/fontbuild/instanceNames.py | 232 ++++++++++++++++++ misc/pylib/fontbuild/italics.py | 308 ++++++++++++++++++++++++ misc/pylib/fontbuild/markFeature.py | 55 +++++ misc/pylib/fontbuild/mitreGlyph.py | 111 +++++++++ misc/pylib/fontbuild/mix.py | 360 ++++++++++++++++++++++++++++ 16 files changed, 2657 insertions(+) create mode 100644 misc/pylib/fontbuild/Build.py create mode 100644 misc/pylib/fontbuild/LICENSE create mode 100644 misc/pylib/fontbuild/ORIGIN.txt create mode 100644 misc/pylib/fontbuild/__init__.py create mode 100644 misc/pylib/fontbuild/alignpoints.py create mode 100644 misc/pylib/fontbuild/anchors.py create mode 100644 misc/pylib/fontbuild/convertCurves.py create mode 100644 misc/pylib/fontbuild/curveFitPen.py create mode 100644 misc/pylib/fontbuild/decomposeGlyph.py create mode 100755 misc/pylib/fontbuild/features.py create mode 100644 misc/pylib/fontbuild/generateGlyph.py create mode 100644 misc/pylib/fontbuild/instanceNames.py create mode 100644 misc/pylib/fontbuild/italics.py create mode 100755 misc/pylib/fontbuild/markFeature.py create mode 100644 misc/pylib/fontbuild/mitreGlyph.py create mode 100644 misc/pylib/fontbuild/mix.py (limited to 'misc/pylib') diff --git a/misc/pylib/fontbuild/Build.py b/misc/pylib/fontbuild/Build.py new file mode 100644 index 000000000..5046f9f91 --- /dev/null +++ b/misc/pylib/fontbuild/Build.py @@ -0,0 +1,300 @@ +# Copyright 2015 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +import ConfigParser +import os +import sys + +from booleanOperations import BooleanOperationManager +from cu2qu.ufo import fonts_to_quadratic +from fontTools.misc.transform import Transform +from robofab.world import OpenFont +from ufo2ft import compileOTF, compileTTF + +from fontbuild.decomposeGlyph import decomposeGlyph +from fontbuild.features import readFeatureFile, writeFeatureFile +from fontbuild.generateGlyph import generateGlyph +from fontbuild.instanceNames import setInfoRF +from fontbuild.italics import italicizeGlyph +from fontbuild.markFeature import RobotoFeatureCompiler, RobotoKernWriter +from fontbuild.mitreGlyph import mitreGlyph +from fontbuild.mix import Mix,Master,narrowFLGlyph + + +class FontProject: + + def __init__(self, basefont, basedir, configfile, buildTag=''): + self.basefont = basefont + self.basedir = basedir + self.config = ConfigParser.RawConfigParser() + self.configfile = os.path.join(self.basedir, configfile) + self.config.read(self.configfile) + self.buildTag = buildTag + + self.diacriticList = [ + line.strip() for line in self.openResource("diacriticfile") + if not line.startswith("#")] + self.adobeGlyphList = dict( + line.split(";") for line in self.openResource("agl_glyphlistfile") + if not line.startswith("#")) + self.glyphOrder = self.openResource("glyphorder") + + # map exceptional glyph names in Roboto to names in the AGL + roboNames = ( + ('Obar', 'Ocenteredtilde'), ('obar', 'obarred'), + ('eturn', 'eturned'), ('Iota1', 'Iotaafrican')) + for roboName, aglName in roboNames: + self.adobeGlyphList[roboName] = self.adobeGlyphList[aglName] + + self.builddir = "out" + self.decompose = self.config.get("glyphs","decompose").split() + self.predecompose = self.config.get("glyphs","predecompose").split() + self.lessItalic = self.config.get("glyphs","lessitalic").split() + self.deleteList = self.config.get("glyphs","delete").split() + self.noItalic = self.config.get("glyphs","noitalic").split() + + self.buildOTF = False + self.compatible = False + self.generatedFonts = [] + + def openResource(self, name): + with open(os.path.join( + self.basedir, self.config.get("res", name))) as resourceFile: + resource = resourceFile.read() + return resource.splitlines() + + def generateOutputPath(self, font, ext): + family = font.info.familyName.replace(" ", "") + style = font.info.styleName.replace(" ", "") + path = os.path.join(self.basedir, self.builddir, family + ext.upper()) + if not os.path.exists(path): + os.makedirs(path) + return os.path.join(path, "%s-%s.%s" % (family, style, ext)) + + def generateFont(self, mix, names, italic=False, swapSuffixes=None, stemWidth=185, + italicMeanYCenter=-825, italicNarrowAmount=1): + + n = names.split("/") + log("---------------------\n%s %s\n----------------------" %(n[0],n[1])) + log(">> Mixing masters") + if isinstance( mix, Mix): + f = mix.generateFont(self.basefont) + else: + f = mix.copy() + + if italic == True: + log(">> Italicizing") + i = 0 + for g in f: + i += 1 + if i % 10 == 0: print g.name + + if g.name == "uniFFFD": + continue + + decomposeGlyph(f, g) + removeGlyphOverlap(g) + + if g.name in self.lessItalic: + italicizeGlyph(f, g, 9, stemWidth=stemWidth, + meanYCenter=italicMeanYCenter, + narrowAmount=italicNarrowAmount) + elif g.name not in self.noItalic: + italicizeGlyph(f, g, 10, stemWidth=stemWidth, + meanYCenter=italicMeanYCenter, + narrowAmount=italicNarrowAmount) + if g.width != 0: + g.width += 10 + + # set the oblique flag in fsSelection + f.info.openTypeOS2Selection.append(9) + + if swapSuffixes != None: + for swap in swapSuffixes: + swapList = [g.name for g in f if g.name.endswith(swap)] + for gname in swapList: + print gname + swapContours(f, gname.replace(swap,""), gname) + for gname in self.predecompose: + if f.has_key(gname): + decomposeGlyph(f, f[gname]) + + log(">> Generating glyphs") + generateGlyphs(f, self.diacriticList, self.adobeGlyphList) + log(">> Copying features") + readFeatureFile(f, self.basefont.features.text) + log(">> Decomposing") + for g in f: + if len(g.components) > 0: + decomposeGlyph(f, g) + # for gname in self.decompose: + # if f.has_key(gname): + # decomposeGlyph(f, f[gname]) + + copyrightHolderName = '' + if self.config.has_option('main', 'copyrightHolderName'): + copyrightHolderName = self.config.get('main', 'copyrightHolderName') + + def getcfg(name, fallback=''): + if self.config.has_option('main', name): + return self.config.get('main', name) + else: + return fallback + + setInfoRF(f, n, { + 'foundry': getcfg('foundry'), + 'foundryURL': getcfg('foundryURL'), + 'designer': getcfg('designer'), + 'copyrightHolderName': getcfg('copyrightHolderName'), + 'build': self.buildTag, + 'version': getcfg('version'), + 'license': getcfg('license'), + 'licenseURL': getcfg('licenseURL'), + }) + + if not self.compatible: + cleanCurves(f) + deleteGlyphs(f, self.deleteList) + + log(">> Generating font files") + ufoName = self.generateOutputPath(f, "ufo") + f.save(ufoName) + self.generatedFonts.append(ufoName) + + if self.buildOTF: + log(">> Generating OTF file") + newFont = OpenFont(ufoName) + otfName = self.generateOutputPath(f, "otf") + saveOTF(newFont, otfName, self.glyphOrder) + + def generateTTFs(self): + """Build TTF for each font generated since last call to generateTTFs.""" + + fonts = [OpenFont(ufo) for ufo in self.generatedFonts] + self.generatedFonts = [] + + log(">> Converting curves to quadratic") + # using a slightly higher max error (e.g. 0.0025 em), dots will have + # fewer control points and look noticeably different + max_err = 0.001 + if self.compatible: + fonts_to_quadratic(fonts, max_err_em=max_err, dump_stats=True, reverse_direction=True) + else: + for font in fonts: + fonts_to_quadratic([font], max_err_em=max_err, dump_stats=True, reverse_direction=True) + + log(">> Generating TTF files") + for font in fonts: + ttfName = self.generateOutputPath(font, "ttf") + log(os.path.basename(ttfName)) + saveOTF(font, ttfName, self.glyphOrder, truetype=True) + + +def transformGlyphMembers(g, m): + g.width = int(g.width * m.a) + g.Transform(m) + for a in g.anchors: + p = Point(a.p) + p.Transform(m) + a.p = p + for c in g.components: + # Assumes that components have also been individually transformed + p = Point(0,0) + d = Point(c.deltas[0]) + d.Transform(m) + p.Transform(m) + d1 = d - p + c.deltas[0].x = d1.x + c.deltas[0].y = d1.y + s = Point(c.scale) + s.Transform(m) + #c.scale = s + + +def swapContours(f,gName1,gName2): + try: + g1 = f[gName1] + g2 = f[gName2] + except KeyError: + log("swapGlyphs failed for %s %s" % (gName1, gName2)) + return + g3 = g1.copy() + + while g1.contours: + g1.removeContour(0) + for contour in g2.contours: + g1.appendContour(contour) + g1.width = g2.width + + while g2.contours: + g2.removeContour(0) + for contour in g3.contours: + g2.appendContour(contour) + g2.width = g3.width + + +def log(msg): + print msg + + +def generateGlyphs(f, glyphNames, glyphList={}): + log(">> Generating diacritics") + glyphnames = [gname for gname in glyphNames if not gname.startswith("#") and gname != ""] + + for glyphName in glyphNames: + generateGlyph(f, glyphName, glyphList) + +def cleanCurves(f): + log(">> Removing overlaps") + for g in f: + removeGlyphOverlap(g) + + # log(">> Mitring sharp corners") + # for g in f: + # mitreGlyph(g, 3., .7) + + # log(">> Converting curves to quadratic") + # for g in f: + # glyphCurvesToQuadratic(g) + + +def deleteGlyphs(f, deleteList): + for name in deleteList: + if f.has_key(name): + f.removeGlyph(name) + + +def removeGlyphOverlap(glyph): + """Remove overlaps in contours from a glyph.""" + #TODO(jamesgk) verify overlaps exist first, as per library's recommendation + manager = BooleanOperationManager() + contours = glyph.contours + glyph.clearContours() + manager.union(contours, glyph.getPointPen()) + + +def saveOTF(font, destFile, glyphOrder, truetype=False): + """Save a RoboFab font as an OTF binary using ufo2fdk.""" + + if truetype: + otf = compileTTF(font, featureCompilerClass=RobotoFeatureCompiler, + kernWriter=RobotoKernWriter, glyphOrder=glyphOrder, + convertCubics=False, + useProductionNames=False) + else: + otf = compileOTF(font, featureCompilerClass=RobotoFeatureCompiler, + kernWriter=RobotoKernWriter, glyphOrder=glyphOrder, + useProductionNames=False) + otf.save(destFile) diff --git a/misc/pylib/fontbuild/LICENSE b/misc/pylib/fontbuild/LICENSE new file mode 100644 index 000000000..261eeb9e9 --- /dev/null +++ b/misc/pylib/fontbuild/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/misc/pylib/fontbuild/ORIGIN.txt b/misc/pylib/fontbuild/ORIGIN.txt new file mode 100644 index 000000000..1b0a3cf79 --- /dev/null +++ b/misc/pylib/fontbuild/ORIGIN.txt @@ -0,0 +1 @@ +https://github.com/google/roboto/tree/master/scripts/lib/fontbuild diff --git a/misc/pylib/fontbuild/__init__.py b/misc/pylib/fontbuild/__init__.py new file mode 100644 index 000000000..4ed720308 --- /dev/null +++ b/misc/pylib/fontbuild/__init__.py @@ -0,0 +1,6 @@ +""" +fontbuild + +A collection of font production tools written for FontLab +""" +version = "0.1" \ No newline at end of file diff --git a/misc/pylib/fontbuild/alignpoints.py b/misc/pylib/fontbuild/alignpoints.py new file mode 100644 index 000000000..f49f24d95 --- /dev/null +++ b/misc/pylib/fontbuild/alignpoints.py @@ -0,0 +1,173 @@ +# Copyright 2015 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +import math + +import numpy as np +from numpy.linalg import lstsq + + +def alignCorners(glyph, va, subsegments): + out = va.copy() + # for i,c in enumerate(subsegments): + # segmentCount = len(glyph.contours[i].segments) - 1 + # n = len(c) + # for j,s in enumerate(c): + # if j < segmentCount: + # seg = glyph.contours[i].segments[j] + # if seg.type == "line": + # subIndex = subsegmentIndex(i,j,subsegments) + # out[subIndex] = alignPoints(va[subIndex]) + + for i,c in enumerate(subsegments): + segmentCount = len(glyph.contours[i].segments) + n = len(c) + for j,s in enumerate(c): + if j < segmentCount - 1: + segType = glyph.contours[i].segments[j].type + segnextType = glyph.contours[i].segments[j+1].type + next = j+1 + elif j == segmentCount -1 and s[1] > 3: + segType = glyph.contours[i].segments[j].type + segNextType = "line" + next = j+1 + elif j == segmentCount: + segType = "line" + segnextType = glyph.contours[i].segments[1].type + if glyph.name == "J": + print s[1] + print segnextType + next = 1 + else: + break + if segType == "line" and segnextType == "line": + subIndex = subsegmentIndex(i,j,subsegments) + pts = va[subIndex] + ptsnext = va[subsegmentIndex(i,next,subsegments)] + # out[subIndex[-1]] = (out[subIndex[-1]] - 500) * 3 + 500 #findCorner(pts, ptsnext) + # print subIndex[-1], subIndex, subsegmentIndex(i,next,subsegments) + try: + out[subIndex[-1]] = findCorner(pts, ptsnext) + except: + pass + # print glyph.name, "Can't find corner: parallel lines" + return out + + +def subsegmentIndex(contourIndex, segmentIndex, subsegments): + # This whole thing is so dumb. Need a better data model for subsegments + + contourOffset = 0 + for i,c in enumerate(subsegments): + if i == contourIndex: + break + contourOffset += c[-1][0] + n = subsegments[contourIndex][-1][0] + # print contourIndex, contourOffset, n + startIndex = subsegments[contourIndex][segmentIndex-1][0] + segmentCount = subsegments[contourIndex][segmentIndex][1] + endIndex = (startIndex + segmentCount + 1) % (n) + + indices = np.array([(startIndex + i) % (n) + contourOffset for i in range(segmentCount + 1)]) + return indices + + +def alignPoints(pts, start=None, end=None): + if start == None or end == None: + start, end = fitLine(pts) + out = pts.copy() + for i,p in enumerate(pts): + out[i] = nearestPoint(start, end, p) + return out + + +def findCorner(pp, nn): + if len(pp) < 4 or len(nn) < 4: + assert 0, "line too short to fit" + pStart,pEnd = fitLine(pp) + nStart,nEnd = fitLine(nn) + prev = pEnd - pStart + next = nEnd - nStart + # print int(np.arctan2(prev[1],prev[0]) / math.pi * 180), + # print int(np.arctan2(next[1],next[0]) / math.pi * 180) + # if lines are parallel, return simple average of end and start points + if np.dot(prev / np.linalg.norm(prev), + next / np.linalg.norm(next)) > .999999: + # print "parallel lines", np.arctan2(prev[1],prev[0]), np.arctan2(next[1],next[0]) + # print prev, next + assert 0, "parallel lines" + if glyph.name is None: + # Never happens, but here to fix a bug in Python 2.7 with -OO + print '' + return lineIntersect(pStart, pEnd, nStart, nEnd) + + +def lineIntersect((x1,y1),(x2,y2),(x3,y3),(x4,y4)): + x12 = x1 - x2 + x34 = x3 - x4 + y12 = y1 - y2 + y34 = y3 - y4 + + det = x12 * y34 - y12 * x34 + if det == 0: + print "parallel!" + + a = x1 * y2 - y1 * x2 + b = x3 * y4 - y3 * x4 + + x = (a * x34 - b * x12) / det + y = (a * y34 - b * y12) / det + + return (x,y) + + +def fitLineLSQ(pts): + "returns a line fit with least squares. Fails for vertical lines" + n = len(pts) + a = np.ones((n,2)) + for i in range(n): + a[i,0] = pts[i,0] + line = lstsq(a,pts[:,1])[0] + return line + + +def fitLine(pts): + """returns a start vector and direction vector + Assumes points segments that already form a somewhat smooth line + """ + n = len(pts) + if n < 1: + return (0,0),(0,0) + a = np.zeros((n-1,2)) + for i in range(n-1): + v = pts[i] - pts[i+1] + a[i] = v / np.linalg.norm(v) + direction = np.mean(a[1:-1], axis=0) + start = np.mean(pts[1:-1], axis=0) + return start, start+direction + + +def nearestPoint(a,b,c): + "nearest point to point c on line a_b" + magnitude = np.linalg.norm(b-a) + if magnitude == 0: + raise Exception, "Line segment cannot be 0 length" + return (b-a) * np.dot((c-a) / magnitude, (b-a) / magnitude) + a + + +# pts = np.array([[1,1],[2,2],[3,3],[4,4]]) +# pts2 = np.array([[1,0],[2,0],[3,0],[4,0]]) +# print alignPoints(pts2, start = pts[0], end = pts[0]+pts[0]) +# # print findCorner(pts,pts2) diff --git a/misc/pylib/fontbuild/anchors.py b/misc/pylib/fontbuild/anchors.py new file mode 100644 index 000000000..a617b2f51 --- /dev/null +++ b/misc/pylib/fontbuild/anchors.py @@ -0,0 +1,77 @@ +# Copyright 2015 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +def getGlyph(gname, font): + return font[gname] if font.has_key(gname) else None + + +def getComponentByName(f, g, componentName): + for c in g.components: + if c.baseGlyph == componentName: + return c + +def getAnchorByName(g,anchorName): + for a in g.anchors: + if a.name == anchorName: + return a + +def moveMarkAnchors(f, g, anchorName, accentName, dx, dy): + if "top"==anchorName: + anchors = f[accentName].anchors + for anchor in anchors: + if "mkmktop_acc" == anchor.name: + for anc in g.anchors: + if anc.name == "top": + g.removeAnchor(anc) + break + g.appendAnchor("top", (anchor.x + int(dx), anchor.y + int(dy))) + + elif anchorName in ["bottom", "bottomu"]: + anchors = f[accentName].anchors + for anchor in anchors: + if "mkmkbottom_acc" == anchor.name: + for anc in g.anchors: + if anc.name == "bottom": + g.removeAnchor(anc) + break + x = anchor.x + int(dx) + for anc in anchors: + if "top" == anc.name: + x = anc.x + int(dx) + g.appendAnchor("bottom", (x, anchor.y + int(dy))) + + +def alignComponentToAnchor(f,glyphName,baseName,accentName,anchorName): + g = getGlyph(glyphName,f) + base = getGlyph(baseName,f) + accent = getGlyph(accentName,f) + if g == None or base == None or accent == None: + return + a1 = getAnchorByName(base,anchorName) + a2 = getAnchorByName(accent,"_" + anchorName) + if a1 == None or a2 == None: + return + offset = (a1.x - a2.x, a1.y - a2.y) + c = getComponentByName(f, g, accentName) + c.offset = offset + moveMarkAnchors(f, g, anchorName, accentName, offset[0], offset[1]) + + +def alignComponentsToAnchors(f,glyphName,baseName,accentNames): + for a in accentNames: + if len(a) == 1: + continue + alignComponentToAnchor(f,glyphName,baseName,a[0],a[1]) + diff --git a/misc/pylib/fontbuild/convertCurves.py b/misc/pylib/fontbuild/convertCurves.py new file mode 100644 index 000000000..b6efd5ca2 --- /dev/null +++ b/misc/pylib/fontbuild/convertCurves.py @@ -0,0 +1,102 @@ +#! /usr/bin/env python +# +# Copyright 2015 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Converts a cubic bezier curve to a quadratic spline with +exactly two off curve points. + +""" + +import numpy +from numpy import array,cross,dot +from fontTools.misc import bezierTools +from robofab.objects.objectsRF import RSegment + +def replaceSegments(contour, segments): + while len(contour): + contour.removeSegment(0) + for s in segments: + contour.appendSegment(s.type, [(p.x, p.y) for p in s.points], s.smooth) + +def calcIntersect(a,b,c,d): + numpy.seterr(all='raise') + e = b-a + f = d-c + p = array([-e[1], e[0]]) + try: + h = dot((a-c),p) / dot(f,p) + except: + print a,b,c,d + raise + return c + dot(f,h) + +def simpleConvertToQuadratic(p0,p1,p2,p3): + p = [array(i.x,i.y) for i in [p0,p1,p2,p3]] + off = calcIntersect(p[0],p[1],p[2],p[3]) + +# OFFCURVE_VECTOR_CORRECTION = -.015 +OFFCURVE_VECTOR_CORRECTION = 0 + +def convertToQuadratic(p0,p1,p2,p3): + # TODO: test for accuracy and subdivide further if needed + p = [(i.x,i.y) for i in [p0,p1,p2,p3]] + # if p[0][0] == p[1][0] and p[0][0] == p[2][0] and p[0][0] == p[2][0] and p[0][0] == p[3][0]: + # return (p[0],p[1],p[2],p[3]) + # if p[0][1] == p[1][1] and p[0][1] == p[2][1] and p[0][1] == p[2][1] and p[0][1] == p[3][1]: + # return (p[0],p[1],p[2],p[3]) + seg1,seg2 = bezierTools.splitCubicAtT(p[0], p[1], p[2], p[3], .5) + pts1 = [array([i[0], i[1]]) for i in seg1] + pts2 = [array([i[0], i[1]]) for i in seg2] + on1 = seg1[0] + on2 = seg2[3] + try: + off1 = calcIntersect(pts1[0], pts1[1], pts1[2], pts1[3]) + off2 = calcIntersect(pts2[0], pts2[1], pts2[2], pts2[3]) + except: + return (p[0],p[1],p[2],p[3]) + off1 = (on1 - off1) * OFFCURVE_VECTOR_CORRECTION + off1 + off2 = (on2 - off2) * OFFCURVE_VECTOR_CORRECTION + off2 + return (on1,off1,off2,on2) + +def cubicSegmentToQuadratic(c,sid): + + segment = c[sid] + if (segment.type != "curve"): + print "Segment type not curve" + return + + #pSegment,junk = getPrevAnchor(c,sid) + pSegment = c[sid-1] #assumes that a curve type will always be proceeded by another point on the same contour + points = convertToQuadratic(pSegment.points[-1],segment.points[0], + segment.points[1],segment.points[2]) + return RSegment( + 'qcurve', [[int(i) for i in p] for p in points[1:]], segment.smooth) + +def glyphCurvesToQuadratic(g): + + for c in g: + segments = [] + for i in range(len(c)): + s = c[i] + if s.type == "curve": + try: + segments.append(cubicSegmentToQuadratic(c, i)) + except Exception: + print g.name, i + raise + else: + segments.append(s) + replaceSegments(c, segments) diff --git a/misc/pylib/fontbuild/curveFitPen.py b/misc/pylib/fontbuild/curveFitPen.py new file mode 100644 index 000000000..f7c0caed9 --- /dev/null +++ b/misc/pylib/fontbuild/curveFitPen.py @@ -0,0 +1,422 @@ +#! /opt/local/bin/pythonw2.7 +# +# Copyright 2015 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +__all__ = ["SubsegmentPen","SubsegmentsToCurvesPen", "segmentGlyph", "fitGlyph"] + + +from fontTools.pens.basePen import BasePen +import numpy as np +from numpy import array as v +from numpy.linalg import norm +from robofab.pens.adapterPens import GuessSmoothPointPen +from robofab.pens.pointPen import BasePointToSegmentPen + + +class SubsegmentsToCurvesPointPen(BasePointToSegmentPen): + def __init__(self, glyph, subsegmentGlyph, subsegments): + BasePointToSegmentPen.__init__(self) + self.glyph = glyph + self.subPen = SubsegmentsToCurvesPen(None, glyph.getPen(), subsegmentGlyph, subsegments) + + def setMatchTangents(self, b): + self.subPen.matchTangents = b + + def _flushContour(self, segments): + # + # adapted from robofab.pens.adapterPens.rfUFOPointPen + # + assert len(segments) >= 1 + # if we only have one point and it has a name, we must have an anchor + first = segments[0] + segmentType, points = first + pt, smooth, name, kwargs = points[0] + if len(segments) == 1 and name != None: + self.glyph.appendAnchor(name, pt) + return + else: + segmentType, points = segments[-1] + movePt, smooth, name, kwargs = points[-1] + if smooth: + # last point is smooth, set pen to start smooth + self.subPen.setLastSmooth(True) + if segmentType == 'line': + del segments[-1] + + self.subPen.moveTo(movePt) + + # do the rest of the segments + for segmentType, points in segments: + isSmooth = True in [smooth for pt, smooth, name, kwargs in points] + pp = [pt for pt, smooth, name, kwargs in points] + if segmentType == "line": + assert len(pp) == 1 + if isSmooth: + self.subPen.smoothLineTo(pp[0]) + else: + self.subPen.lineTo(pp[0]) + elif segmentType == "curve": + assert len(pp) == 3 + if isSmooth: + self.subPen.smoothCurveTo(*pp) + else: + self.subPen.curveTo(*pp) + elif segmentType == "qcurve": + assert 0, "qcurve not supported" + else: + assert 0, "illegal segmentType: %s" % segmentType + self.subPen.closePath() + + def addComponent(self, glyphName, transform): + self.subPen.addComponent(glyphName, transform) + + +class SubsegmentsToCurvesPen(BasePen): + def __init__(self, glyphSet, otherPen, subsegmentGlyph, subsegments): + BasePen.__init__(self, None) + self.otherPen = otherPen + self.ssglyph = subsegmentGlyph + self.subsegments = subsegments + self.contourIndex = -1 + self.segmentIndex = -1 + self.lastPoint = (0,0) + self.lastSmooth = False + self.nextSmooth = False + + def setLastSmooth(self, b): + self.lastSmooth = b + + def _moveTo(self, (x, y)): + self.contourIndex += 1 + self.segmentIndex = 0 + self.startPoint = (x,y) + p = self.ssglyph.contours[self.contourIndex][0].points[0] + self.otherPen.moveTo((p.x, p.y)) + self.lastPoint = (x,y) + + def _lineTo(self, (x, y)): + self.segmentIndex += 1 + index = self.subsegments[self.contourIndex][self.segmentIndex][0] + p = self.ssglyph.contours[self.contourIndex][index].points[0] + self.otherPen.lineTo((p.x, p.y)) + self.lastPoint = (x,y) + self.lastSmooth = False + + def smoothLineTo(self, (x, y)): + self.lineTo((x,y)) + self.lastSmooth = True + + def smoothCurveTo(self, (x1, y1), (x2, y2), (x3, y3)): + self.nextSmooth = True + self.curveTo((x1, y1), (x2, y2), (x3, y3)) + self.nextSmooth = False + self.lastSmooth = True + + def _curveToOne(self, (x1, y1), (x2, y2), (x3, y3)): + self.segmentIndex += 1 + c = self.ssglyph.contours[self.contourIndex] + n = len(c) + startIndex = (self.subsegments[self.contourIndex][self.segmentIndex-1][0]) + segmentCount = (self.subsegments[self.contourIndex][self.segmentIndex][1]) + endIndex = (startIndex + segmentCount + 1) % (n) + + indices = [(startIndex + i) % (n) for i in range(segmentCount + 1)] + points = np.array([(c[i].points[0].x, c[i].points[0].y) for i in indices]) + prevPoint = (c[(startIndex - 1)].points[0].x, c[(startIndex - 1)].points[0].y) + nextPoint = (c[(endIndex) % n].points[0].x, c[(endIndex) % n].points[0].y) + prevTangent = prevPoint - points[0] + nextTangent = nextPoint - points[-1] + + tangent1 = points[1] - points[0] + tangent3 = points[-2] - points[-1] + prevTangent /= np.linalg.norm(prevTangent) + nextTangent /= np.linalg.norm(nextTangent) + tangent1 /= np.linalg.norm(tangent1) + tangent3 /= np.linalg.norm(tangent3) + + tangent1, junk = self.smoothTangents(tangent1, prevTangent, self.lastSmooth) + tangent3, junk = self.smoothTangents(tangent3, nextTangent, self.nextSmooth) + if self.matchTangents == True: + cp = fitBezier(points, tangent1, tangent3) + cp[1] = norm(cp[1] - cp[0]) * tangent1 / norm(tangent1) + cp[0] + cp[2] = norm(cp[2] - cp[3]) * tangent3 / norm(tangent3) + cp[3] + else: + cp = fitBezier(points) + # if self.ssglyph.name == 'r': + # print "-----------" + # print self.lastSmooth, self.nextSmooth + # print "%i %i : %i %i \n %i %i : %i %i \n %i %i : %i %i"%(x1,y1, cp[1,0], cp[1,1], x2,y2, cp[2,0], cp[2,1], x3,y3, cp[3,0], cp[3,1]) + self.otherPen.curveTo((cp[1,0], cp[1,1]), (cp[2,0], cp[2,1]), (cp[3,0], cp[3,1])) + self.lastPoint = (x3, y3) + self.lastSmooth = False + + def smoothTangents(self,t1,t2,forceSmooth = False): + if forceSmooth or (abs(t1.dot(t2)) > .95 and norm(t1-t2) > 1): + # print t1,t2, + t1 = (t1 - t2) / 2 + t2 = -t1 + # print t1,t2 + return t1 / norm(t1), t2 / norm(t2) + + def _closePath(self): + self.otherPen.closePath() + + def _endPath(self): + self.otherPen.endPath() + + def addComponent(self, glyphName, transformation): + self.otherPen.addComponent(glyphName, transformation) + + +class SubsegmentPointPen(BasePointToSegmentPen): + def __init__(self, glyph, resolution): + BasePointToSegmentPen.__init__(self) + self.glyph = glyph + self.resolution = resolution + self.subPen = SubsegmentPen(None, glyph.getPen()) + + def getSubsegments(self): + return self.subPen.subsegments[:] + + def _flushContour(self, segments): + # + # adapted from robofab.pens.adapterPens.rfUFOPointPen + # + assert len(segments) >= 1 + # if we only have one point and it has a name, we must have an anchor + first = segments[0] + segmentType, points = first + pt, smooth, name, kwargs = points[0] + if len(segments) == 1 and name != None: + self.glyph.appendAnchor(name, pt) + return + else: + segmentType, points = segments[-1] + movePt, smooth, name, kwargs = points[-1] + if segmentType == 'line': + del segments[-1] + + self.subPen.moveTo(movePt) + + # do the rest of the segments + for segmentType, points in segments: + points = [pt for pt, smooth, name, kwargs in points] + if segmentType == "line": + assert len(points) == 1 + self.subPen.lineTo(points[0]) + elif segmentType == "curve": + assert len(points) == 3 + self.subPen.curveTo(*points) + elif segmentType == "qcurve": + assert 0, "qcurve not supported" + else: + assert 0, "illegal segmentType: %s" % segmentType + self.subPen.closePath() + + def addComponent(self, glyphName, transform): + self.subPen.addComponent(glyphName, transform) + + +class SubsegmentPen(BasePen): + + def __init__(self, glyphSet, otherPen, resolution=25): + BasePen.__init__(self,glyphSet) + self.resolution = resolution + self.otherPen = otherPen + self.subsegments = [] + self.startContour = (0,0) + self.contourIndex = -1 + + def _moveTo(self, (x, y)): + self.contourIndex += 1 + self.segmentIndex = 0 + self.subsegments.append([]) + self.subsegmentCount = 0 + self.subsegments[self.contourIndex].append([self.subsegmentCount, 0]) + self.startContour = (x,y) + self.lastPoint = (x,y) + self.otherPen.moveTo((x,y)) + + def _lineTo(self, (x, y)): + count = self.stepsForSegment((x,y),self.lastPoint) + if count < 1: + count = 1 + self.subsegmentCount += count + self.subsegments[self.contourIndex].append([self.subsegmentCount, count]) + for i in range(1,count+1): + x1 = self.lastPoint[0] + (x - self.lastPoint[0]) * i/float(count) + y1 = self.lastPoint[1] + (y - self.lastPoint[1]) * i/float(count) + self.otherPen.lineTo((x1,y1)) + self.lastPoint = (x,y) + + def _curveToOne(self, (x1, y1), (x2, y2), (x3, y3)): + count = self.stepsForSegment((x3,y3),self.lastPoint) + if count < 2: + count = 2 + self.subsegmentCount += count + self.subsegments[self.contourIndex].append([self.subsegmentCount,count]) + x = self.renderCurve((self.lastPoint[0],x1,x2,x3),count) + y = self.renderCurve((self.lastPoint[1],y1,y2,y3),count) + assert len(x) == count + if (x3 == self.startContour[0] and y3 == self.startContour[1]): + count -= 1 + for i in range(count): + self.otherPen.lineTo((x[i],y[i])) + self.lastPoint = (x3,y3) + + def _closePath(self): + if not (self.lastPoint[0] == self.startContour[0] and self.lastPoint[1] == self.startContour[1]): + self._lineTo(self.startContour) + + # round values used by otherPen (a RoboFab SegmentToPointPen) to decide + # whether to delete duplicate points at start and end of contour + #TODO(jamesgk) figure out why we have to do this hack, then remove it + c = self.otherPen.contour + for i in [0, -1]: + c[i] = [[round(n, 5) for n in c[i][0]]] + list(c[i][1:]) + + self.otherPen.closePath() + + def _endPath(self): + self.otherPen.endPath() + + def addComponent(self, glyphName, transformation): + self.otherPen.addComponent(glyphName, transformation) + + def stepsForSegment(self, p1, p2): + dist = np.linalg.norm(v(p1) - v(p2)) + out = int(dist / self.resolution) + return out + + def renderCurve(self,p,count): + curvePoints = [] + t = 1.0 / float(count) + temp = t * t + + f = p[0] + fd = 3 * (p[1] - p[0]) * t + fdd_per_2 = 3 * (p[0] - 2 * p[1] + p[2]) * temp + fddd_per_2 = 3 * (3 * (p[1] - p[2]) + p[3] - p[0]) * temp * t + + fddd = fddd_per_2 + fddd_per_2 + fdd = fdd_per_2 + fdd_per_2 + fddd_per_6 = fddd_per_2 * (1.0 / 3) + + for i in range(count): + f = f + fd + fdd_per_2 + fddd_per_6 + fd = fd + fdd + fddd_per_2 + fdd = fdd + fddd + fdd_per_2 = fdd_per_2 + fddd_per_2 + curvePoints.append(f) + + return curvePoints + + +def fitBezierSimple(pts): + T = [np.linalg.norm(pts[i]-pts[i-1]) for i in range(1,len(pts))] + tsum = np.sum(T) + T = [0] + T + T = [np.sum(T[0:i+1])/tsum for i in range(len(pts))] + T = [[t**3, t**2, t, 1] for t in T] + T = np.array(T) + M = np.array([[-1, 3, -3, 1], + [ 3, -6, 3, 0], + [-3, 3, 0, 0], + [ 1, 0, 0, 0]]) + T = T.dot(M) + T = np.concatenate((T, np.array([[100,0,0,0], [0,0,0,100]]))) + # pts = np.vstack((pts, pts[0] * 100, pts[-1] * 100)) + C = np.linalg.lstsq(T, pts) + return C[0] + + +def subdivideLineSegment(pts): + out = [pts[0]] + for i in range(1, len(pts)): + out.append(pts[i-1] + (pts[i] - pts[i-1]) * .5) + out.append(pts[i]) + return np.array(out) + + +def fitBezier(pts,tangent0=None,tangent3=None): + if len(pts < 4): + pts = subdivideLineSegment(pts) + T = [np.linalg.norm(pts[i]-pts[i-1]) for i in range(1,len(pts))] + tsum = np.sum(T) + T = [0] + T + T = [np.sum(T[0:i+1])/tsum for i in range(len(pts))] + T = [[t**3, t**2, t, 1] for t in T] + T = np.array(T) + M = np.array([[-1, 3, -3, 1], + [ 3, -6, 3, 0], + [-3, 3, 0, 0], + [ 1, 0, 0, 0]]) + T = T.dot(M) + n = len(pts) + pout = pts.copy() + pout[:,0] -= (T[:,0] * pts[0,0]) + (T[:,3] * pts[-1,0]) + pout[:,1] -= (T[:,0] * pts[0,1]) + (T[:,3] * pts[-1,1]) + + TT = np.zeros((n*2,4)) + for i in range(n): + for j in range(2): + TT[i*2,j*2] = T[i,j+1] + TT[i*2+1,j*2+1] = T[i,j+1] + pout = pout.reshape((n*2,1),order="C") + + if tangent0 != None and tangent3 != None: + tangentConstraintsT = np.array([ + [tangent0[1], -tangent0[0], 0, 0], + [0, 0, tangent3[1], -tangent3[0]] + ]) + tangentConstraintsP = np.array([ + [pts[0][1] * -tangent0[0] + pts[0][0] * tangent0[1]], + [pts[-1][1] * -tangent3[0] + pts[-1][0] * tangent3[1]] + ]) + TT = np.concatenate((TT, tangentConstraintsT * 1000)) + pout = np.concatenate((pout, tangentConstraintsP * 1000)) + C = np.linalg.lstsq(TT,pout)[0].reshape((2,2)) + return np.array([pts[0], C[0], C[1], pts[-1]]) + + +def segmentGlyph(glyph,resolution=50): + g1 = glyph.copy() + g1.clear() + dp = SubsegmentPointPen(g1, resolution) + glyph.drawPoints(dp) + return g1, dp.getSubsegments() + + +def fitGlyph(glyph, subsegmentGlyph, subsegmentIndices, matchTangents=True): + outGlyph = glyph.copy() + outGlyph.clear() + fitPen = SubsegmentsToCurvesPointPen(outGlyph, subsegmentGlyph, subsegmentIndices) + fitPen.setMatchTangents(matchTangents) + # smoothPen = GuessSmoothPointPen(fitPen) + glyph.drawPoints(fitPen) + outGlyph.width = subsegmentGlyph.width + return outGlyph + + +if __name__ == '__main__': + p = SubsegmentPen(None, None) + pts = np.array([ + [0,0], + [.5,.5], + [.5,.5], + [1,1] + ]) + print np.array(p.renderCurve(pts,10)) * 10 diff --git a/misc/pylib/fontbuild/decomposeGlyph.py b/misc/pylib/fontbuild/decomposeGlyph.py new file mode 100644 index 000000000..0470fa60b --- /dev/null +++ b/misc/pylib/fontbuild/decomposeGlyph.py @@ -0,0 +1,23 @@ +def decomposeGlyph(font, glyph): + """Moves the components of a glyph to its outline.""" + if len(glyph.components): + deepCopyContours(font, glyph, glyph, (0, 0), (1, 1)) + glyph.clearComponents() + + +def deepCopyContours(font, parent, component, offset, scale): + """Copy contours to parent from component, including nested components.""" + + for nested in component.components: + deepCopyContours( + font, parent, font[nested.baseGlyph], + (offset[0] + nested.offset[0], offset[1] + nested.offset[1]), + (scale[0] * nested.scale[0], scale[1] * nested.scale[1])) + + if component == parent: + return + for contour in component: + contour = contour.copy() + contour.scale(scale) + contour.move(offset) + parent.appendContour(contour) diff --git a/misc/pylib/fontbuild/features.py b/misc/pylib/fontbuild/features.py new file mode 100755 index 000000000..fe6eca012 --- /dev/null +++ b/misc/pylib/fontbuild/features.py @@ -0,0 +1,189 @@ +# Copyright 2015 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +import re + +from feaTools import parser +from feaTools.writers.fdkSyntaxWriter import FDKSyntaxFeatureWriter + + +class FilterFeatureWriter(FDKSyntaxFeatureWriter): + """Feature writer to detect invalid references and duplicate definitions.""" + + def __init__(self, refs=set(), name=None, isFeature=False): + """Initializes the set of known references, empty by default.""" + self.refs = refs + self.featureNames = set() + self.lookupNames = set() + self.tableNames = set() + self.languageSystems = set() + super(FilterFeatureWriter, self).__init__( + name=name, isFeature=isFeature) + + # error to print when undefined reference is found in glyph class + self.classErr = ('Undefined reference "%s" removed from glyph class ' + 'definition %s.') + + # error to print when undefined reference is found in sub or pos rule + subErr = ['Substitution rule with undefined reference "%s" removed'] + if self._name: + subErr.append(" from ") + subErr.append("feature" if self._isFeature else "lookup") + subErr.append(' "%s"' % self._name) + subErr.append(".") + self.subErr = "".join(subErr) + self.posErr = self.subErr.replace("Substitution", "Positioning") + + def _subwriter(self, name, isFeature): + """Use this class for nested expressions e.g. in feature definitions.""" + return FilterFeatureWriter(self.refs, name, isFeature) + + def _flattenRefs(self, refs, flatRefs): + """Flatten a list of references.""" + for ref in refs: + if type(ref) == list: + self._flattenRefs(ref, flatRefs) + elif ref != "'": # ignore contextual class markings + flatRefs.append(ref) + + def _checkRefs(self, refs, errorMsg): + """Check a list of references found in a sub or pos rule.""" + flatRefs = [] + self._flattenRefs(refs, flatRefs) + for ref in flatRefs: + # trailing apostrophes should be ignored + if ref[-1] == "'": + ref = ref[:-1] + if ref not in self.refs: + print errorMsg % ref + # insert an empty instruction so that we can't end up with an + # empty block, which is illegal syntax + super(FilterFeatureWriter, self).rawText(";") + return False + return True + + def classDefinition(self, name, contents): + """Check that contents are valid, then add name to known references.""" + if name in self.refs: + return + newContents = [] + for ref in contents: + if ref not in self.refs and ref != "-": + print self.classErr % (ref, name) + else: + newContents.append(ref) + self.refs.add(name) + super(FilterFeatureWriter, self).classDefinition(name, newContents) + + def gsubType1(self, target, replacement): + """Check a sub rule with one-to-one replacement.""" + if self._checkRefs([target, replacement], self.subErr): + super(FilterFeatureWriter, self).gsubType1(target, replacement) + + def gsubType4(self, target, replacement): + """Check a sub rule with many-to-one replacement.""" + if self._checkRefs([target, replacement], self.subErr): + super(FilterFeatureWriter, self).gsubType4(target, replacement) + + def gsubType6(self, precedingContext, target, trailingContext, replacement): + """Check a sub rule with contextual replacement.""" + refs = [precedingContext, target, trailingContext, replacement] + if self._checkRefs(refs, self.subErr): + super(FilterFeatureWriter, self).gsubType6( + precedingContext, target, trailingContext, replacement) + + def gposType1(self, target, value): + """Check a single positioning rule.""" + if self._checkRefs([target], self.posErr): + super(FilterFeatureWriter, self).gposType1(target, value) + + def gposType2(self, target, value, needEnum=False): + """Check a pair positioning rule.""" + if self._checkRefs(target, self.posErr): + super(FilterFeatureWriter, self).gposType2(target, value, needEnum) + + # these rules may contain references, but they aren't present in Roboto + def gsubType3(self, target, replacement): + raise NotImplementedError + + def feature(self, name): + """Adds a feature definition only once.""" + if name not in self.featureNames: + self.featureNames.add(name) + return super(FilterFeatureWriter, self).feature(name) + # we must return a new writer even if we don't add it to this one + return FDKSyntaxFeatureWriter(name, True) + + def lookup(self, name): + """Adds a lookup block only once.""" + if name not in self.lookupNames: + self.lookupNames.add(name) + return super(FilterFeatureWriter, self).lookup(name) + # we must return a new writer even if we don't add it to this one + return FDKSyntaxFeatureWriter(name, False) + + def languageSystem(self, langTag, scriptTag): + """Adds a language system instruction only once.""" + system = (langTag, scriptTag) + if system not in self.languageSystems: + self.languageSystems.add(system) + super(FilterFeatureWriter, self).languageSystem(langTag, scriptTag) + + def table(self, name, data): + """Adds a table only once.""" + if name in self.tableNames: + return + self.tableNames.add(name) + self._instructions.append("table %s {" % name) + self._instructions.extend([" %s %s;" % line for line in data]) + self._instructions.append("} %s;" % name) + + +def compileFeatureRE(name): + """Compiles a feature-matching regex.""" + + # this is the pattern used internally by feaTools: + # https://github.com/typesupply/feaTools/blob/master/Lib/feaTools/parser.py + featureRE = list(parser.featureContentRE) + featureRE.insert(2, name) + featureRE.insert(6, name) + return re.compile("".join(featureRE)) + + +def updateFeature(font, name, value): + """Add a feature definition, or replace existing one.""" + featureRE = compileFeatureRE(name) + if featureRE.search(font.features.text): + font.features.text = featureRE.sub(value, font.features.text) + else: + font.features.text += "\n" + value + + +def readFeatureFile(font, text, prepend=True): + """Incorporate valid definitions from feature text into font.""" + writer = FilterFeatureWriter(set(font.keys())) + if prepend: + text += font.features.text + else: + text = font.features.text + text + parser.parseFeatures(writer, text) + font.features.text = writer.write() + + +def writeFeatureFile(font, path): + """Write the font's features to an external file.""" + fout = open(path, "w") + fout.write(font.features.text) + fout.close() diff --git a/misc/pylib/fontbuild/generateGlyph.py b/misc/pylib/fontbuild/generateGlyph.py new file mode 100644 index 000000000..465f940a9 --- /dev/null +++ b/misc/pylib/fontbuild/generateGlyph.py @@ -0,0 +1,97 @@ +# Copyright 2015 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +import re +from string import find + +from anchors import alignComponentsToAnchors, getAnchorByName + + +def parseComposite(composite): + c = composite.split("=") + d = c[1].split("/") + glyphName = d[0] + if len(d) == 1: + offset = [0, 0] + else: + offset = [int(i) for i in d[1].split(",")] + accentString = c[0] + accents = accentString.split("+") + baseName = accents.pop(0) + accentNames = [i.split(":") for i in accents] + return (glyphName, baseName, accentNames, offset) + + +def copyMarkAnchors(f, g, srcname, width): + for anchor in f[srcname].anchors: + if anchor.name in ("top_dd", "bottom_dd", "top0315"): + g.appendAnchor(anchor.name, (anchor.x + width, anchor.y)) + + if ("top" == anchor.name and + not any(a.name == "parent_top" for a in g.anchors)): + g.appendAnchor("parent_top", anchor.position) + + if ("bottom" == anchor.name and + not any(a.name == "bottom" for a in g.anchors)): + g.appendAnchor("bottom", anchor.position) + + if any(a.name == "top" for a in g.anchors): + return + + anchor_parent_top = getAnchorByName(g, "parent_top") + if anchor_parent_top is not None: + g.appendAnchor("top", anchor_parent_top.position) + + +def generateGlyph(f,gname,glyphList={}): + glyphName, baseName, accentNames, offset = parseComposite(gname) + if f.has_key(glyphName): + print('Existing glyph "%s" found in font, ignoring composition rule ' + '"%s"' % (glyphName, gname)) + return + + if baseName.find("_") != -1: + g = f.newGlyph(glyphName) + for componentName in baseName.split("_"): + g.appendComponent(componentName, (g.width, 0)) + g.width += f[componentName].width + setUnicodeValue(g, glyphList) + + else: + try: + f.compileGlyph(glyphName, baseName, accentNames) + except KeyError as e: + print('KeyError raised for composition rule "%s", likely "%s" ' + 'anchor not found in glyph "%s"' % (gname, e, baseName)) + return + g = f[glyphName] + setUnicodeValue(g, glyphList) + copyMarkAnchors(f, g, baseName, offset[1] + offset[0]) + if len(accentNames) > 0: + alignComponentsToAnchors(f, glyphName, baseName, accentNames) + if offset[0] != 0 or offset[1] != 0: + g.width += offset[1] + offset[0] + g.move((offset[0], 0), anchors=False) + + +def setUnicodeValue(glyph, glyphList): + """Try to ensure glyph has a unicode value -- used by FDK to make OTFs.""" + + if glyph.name in glyphList: + glyph.unicode = int(glyphList[glyph.name], 16) + else: + uvNameMatch = re.match("uni([\dA-F]{4})$", glyph.name) + if uvNameMatch: + glyph.unicode = int(uvNameMatch.group(1), 16) diff --git a/misc/pylib/fontbuild/instanceNames.py b/misc/pylib/fontbuild/instanceNames.py new file mode 100644 index 000000000..cf87ba719 --- /dev/null +++ b/misc/pylib/fontbuild/instanceNames.py @@ -0,0 +1,232 @@ +# Copyright 2015 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +from datetime import date +import re +from random import randint +import string + +class InstanceNames: + "Class that allows easy setting of FontLab name fields. TODO: Add proper italic flags" + + foundry = "" + foundryURL = "" + copyrightHolderName = "" + build = "" + version = "1.0" + year = date.today().year + designer = "" + designerURL = "" + license = "" + licenseURL = "" + + def __init__(self,names): + if type(names) == type(" "): + names = names.split("/") + #print names + self.longfamily = names[0] + self.longstyle = names[1] + self.shortstyle = names[2] + self.subfamilyAbbrev = names[3] + + self.width = self._getWidth() + self.italic = self._getItalic() + self.weight = self._getWeight() + self.fullname = "%s %s" %(self.longfamily, self.longstyle) + self.postscript = re.sub(' ','', self.longfamily) + "-" + re.sub(' ','',self.longstyle) + + if self.subfamilyAbbrev != "" and self.subfamilyAbbrev != None and self.subfamilyAbbrev != "Rg": + self.shortfamily = "%s %s" %(self.longfamily, self.longstyle.split()[0]) + else: + self.shortfamily = self.longfamily + + def setRFNames(self,f, version=1, versionMinor=0): + f.info.familyName = self.longfamily + f.info.styleName = self.longstyle + f.info.styleMapFamilyName = self.shortfamily + f.info.styleMapStyleName = self.shortstyle.lower() + f.info.versionMajor = version + f.info.versionMinor = versionMinor + f.info.year = self.year + if len(self.copyrightHolderName) > 0: + f.info.copyright = "Copyright %s %s" % (self.year, self.copyrightHolderName) + f.info.trademark = "%s is a trademark of %s." %(self.longfamily, self.foundry.rstrip('.')) + + if len(self.designer) > 0: + f.info.openTypeNameDesigner = self.designer + if len(self.designerURL) > 0: + f.info.openTypeNameDesignerURL = self.designerURL + f.info.openTypeNameManufacturer = self.foundry + f.info.openTypeNameManufacturerURL = self.foundryURL + f.info.openTypeNameLicense = self.license + f.info.openTypeNameLicenseURL = self.licenseURL + f.info.openTypeNameVersion = "Version %i.%i" %(version, versionMinor) + + if self.build is not None and len(self.build): + f.info.openTypeNameUniqueID = "%s:%s:%s" %(self.fullname, self.build, self.year) + else: + f.info.openTypeNameUniqueID = "%s:%s" %(self.fullname, self.year) + + # f.info.openTypeNameDescription = "" + # f.info.openTypeNameCompatibleFullName = "" + # f.info.openTypeNameSampleText = "" + if (self.subfamilyAbbrev != "Rg"): + f.info.openTypeNamePreferredFamilyName = self.longfamily + f.info.openTypeNamePreferredSubfamilyName = self.longstyle + + f.info.openTypeOS2WeightClass = self._getWeightCode(self.weight) + f.info.macintoshFONDName = re.sub(' ','',self.longfamily) + " " + re.sub(' ','',self.longstyle) + f.info.postscriptFontName = f.info.macintoshFONDName.replace(" ", "-") + if self.italic: + f.info.italicAngle = -12.0 + + + def setFLNames(self,flFont): + + from FL import NameRecord + + flFont.family_name = self.shortfamily + flFont.mac_compatible = self.fullname + flFont.style_name = self.longstyle + flFont.full_name = self.fullname + flFont.font_name = self.postscript + flFont.font_style = self._getStyleCode() + flFont.menu_name = self.shortfamily + flFont.apple_name = re.sub(' ','',self.longfamily) + " " + re.sub(' ','',self.longstyle) + flFont.fond_id = randint(1000,9999) + flFont.pref_family_name = self.longfamily + flFont.pref_style_name = self.longstyle + flFont.weight = self.weight + flFont.weight_code = self._getWeightCode(self.weight) + flFont.width = self.width + if len(self.italic): + flFont.italic_angle = -12 + + fn = flFont.fontnames + fn.clean() + #fn.append(NameRecord(0,1,0,0, "Font data copyright %s %s" %(self.foundry, self.year) )) + #fn.append(NameRecord(0,3,1,1033, "Font data copyright %s %s" %(self.foundry, self.year) )) + copyrightHolderName = self.copyrightHolderName if len(self.copyrightHolderName) > 0 else self.foundry + fn.append(NameRecord(0,1,0,0, "Copyright %s %s" %(self.year, copyrightHolderName) )) + fn.append(NameRecord(0,3,1,1033, "Copyright %s %s" %(self.year, copyrightHolderName) )) + fn.append(NameRecord(1,1,0,0, self.longfamily )) + fn.append(NameRecord(1,3,1,1033, self.shortfamily )) + fn.append(NameRecord(2,1,0,0, self.longstyle )) + fn.append(NameRecord(2,3,1,1033, self.longstyle )) + #fn.append(NameRecord(3,1,0,0, "%s:%s:%s" %(self.foundry, self.longfamily, self.year) )) + #fn.append(NameRecord(3,3,1,1033, "%s:%s:%s" %(self.foundry, self.longfamily, self.year) )) + fn.append(NameRecord(3,1,0,0, "%s:%s:%s" %(self.foundry, self.fullname, self.year) )) + fn.append(NameRecord(3,3,1,1033, "%s:%s:%s" %(self.foundry, self.fullname, self.year) )) + fn.append(NameRecord(4,1,0,0, self.fullname )) + fn.append(NameRecord(4,3,1,1033, self.fullname )) + if len(self.build) > 0: + fn.append(NameRecord(5,1,0,0, "Version %s%s; %s" %(self.version, self.build, self.year) )) + fn.append(NameRecord(5,3,1,1033, "Version %s%s; %s" %(self.version, self.build, self.year) )) + else: + fn.append(NameRecord(5,1,0,0, "Version %s; %s" %(self.version, self.year) )) + fn.append(NameRecord(5,3,1,1033, "Version %s; %s" %(self.version, self.year) )) + fn.append(NameRecord(6,1,0,0, self.postscript )) + fn.append(NameRecord(6,3,1,1033, self.postscript )) + fn.append(NameRecord(7,1,0,0, "%s is a trademark of %s." %(self.longfamily, self.foundry) )) + fn.append(NameRecord(7,3,1,1033, "%s is a trademark of %s." %(self.longfamily, self.foundry) )) + fn.append(NameRecord(9,1,0,0, self.foundry )) + fn.append(NameRecord(9,3,1,1033, self.foundry )) + fn.append(NameRecord(11,1,0,0, self.foundryURL )) + fn.append(NameRecord(11,3,1,1033, self.foundryURL )) + fn.append(NameRecord(12,1,0,0, self.designer )) + fn.append(NameRecord(12,3,1,1033, self.designer )) + fn.append(NameRecord(13,1,0,0, self.license )) + fn.append(NameRecord(13,3,1,1033, self.license )) + fn.append(NameRecord(14,1,0,0, self.licenseURL )) + fn.append(NameRecord(14,3,1,1033, self.licenseURL )) + if (self.subfamilyAbbrev != "Rg"): + fn.append(NameRecord(16,3,1,1033, self.longfamily )) + fn.append(NameRecord(17,3,1,1033, self.longstyle)) + #else: + #fn.append(NameRecord(17,3,1,1033,"")) + #fn.append(NameRecord(18,1,0,0, re.sub("Italic","It", self.fullname))) + + def _getSubstyle(self, regex): + substyle = re.findall(regex, self.longstyle) + if len(substyle) > 0: + return substyle[0] + else: + return "" + + def _getItalic(self): + return self._getSubstyle(r"Italic|Oblique|Obliq") + + def _getWeight(self): + w = self._getSubstyle(r"Extrabold|Superbold|Super|Fat|Black|Bold|Semibold|Demibold|Medium|Light|Thin") + if w == "": + w = "Regular" + return w + + def _getWidth(self): + w = self._getSubstyle(r"Condensed|Extended|Narrow|Wide") + if w == "": + w = "Normal" + return w + + def _getStyleCode(self): + #print "shortstyle:", self.shortstyle + styleCode = 0 + if self.shortstyle == "Bold": + styleCode = 32 + if self.shortstyle == "Italic": + styleCode = 1 + if self.shortstyle == "Bold Italic": + styleCode = 33 + if self.longstyle == "Regular": + styleCode = 64 + return styleCode + + def _getWeightCode(self,weight): + if weight == "Thin": + return 250 + elif weight == "Light": + return 300 + elif weight == "Bold": + return 700 + elif weight == "Medium": + return 500 + elif weight == "Semibold": + return 600 + elif weight == "Black": + return 900 + elif weight == "Fat": + return 900 + + return 400 + +def setNames(f,names,foundry="",version="1.0",build=""): + InstanceNames.foundry = foundry + InstanceNames.version = version + InstanceNames.build = build + i = InstanceNames(names) + i.setFLNames(f) + + +def setInfoRF(f, names, attrs={}): + i = InstanceNames(names) + version, versionMinor = (1, 0) + for k,v in attrs.iteritems(): + if k == 'version': + if v.find('.') != -1: + version, versionMinor = [int(num) for num in v.split(".")] + else: + version = int(v) + setattr(i, k, v) + i.setRFNames(f, version=version, versionMinor=versionMinor) diff --git a/misc/pylib/fontbuild/italics.py b/misc/pylib/fontbuild/italics.py new file mode 100644 index 000000000..91e658c74 --- /dev/null +++ b/misc/pylib/fontbuild/italics.py @@ -0,0 +1,308 @@ +# Copyright 2015 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +import math + +from fontTools.misc.transform import Transform +import numpy as np +from numpy.linalg import norm +from scipy.sparse.linalg import cg +from scipy.ndimage.filters import gaussian_filter1d as gaussian +from scipy.cluster.vq import vq, whiten + +from fontbuild.alignpoints import alignCorners +from fontbuild.curveFitPen import fitGlyph, segmentGlyph + + +def italicizeGlyph(f, g, angle=10, stemWidth=185, meanYCenter=-825, narrowAmount=1): + unic = g.unicode #save unicode + + glyph = f[g.name] + slope = np.tanh(math.pi * angle / 180) + + # determine how far on the x axis the glyph should slide + # to compensate for the slant. + # meanYCenter: + # -600 is a magic number that assumes a 2048 unit em square, + # and -825 for a 2816 unit em square. (UPM*0.29296875) + m = Transform(1, 0, slope, 1, 0, 0) + xoffset, junk = m.transformPoint((0, meanYCenter)) + m = Transform(narrowAmount, 0, slope, 1, xoffset, 0) + + if len(glyph) > 0: + g2 = italicize(f[g.name], angle, xoffset=xoffset, stemWidth=stemWidth) + f.insertGlyph(g2, g.name) + + transformFLGlyphMembers(f[g.name], m) + + if unic > 0xFFFF: #restore unicode + g.unicode = unic + + +def italicize(glyph, angle=12, stemWidth=180, xoffset=-50): + CURVE_CORRECTION_WEIGHT = .03 + CORNER_WEIGHT = 10 + + # decompose the glyph into smaller segments + ga, subsegments = segmentGlyph(glyph,25) + va, e = glyphToMesh(ga) + n = len(va) + grad = mapEdges(lambda a,(p,n): normalize(p-a), va, e) + cornerWeights = mapEdges(lambda a,(p,n): normalize(p-a).dot(normalize(a-n)), grad, e)[:,0].reshape((-1,1)) + smooth = np.ones((n,1)) * CURVE_CORRECTION_WEIGHT + + controlPoints = findControlPointsInMesh(glyph, va, subsegments) + smooth[controlPoints > 0] = 1 + smooth[cornerWeights < .6] = CORNER_WEIGHT + # smooth[cornerWeights >= .9999] = 1 + + out = va.copy() + hascurves = False + for c in glyph.contours: + for s in c.segments: + if s.type == "curve": + hascurves = True + break + if hascurves: + break + if stemWidth > 100: + outCorrected = skewMesh(recompose(skewMesh(out, angle * 1.6), grad, e, smooth=smooth), -angle * 1.6) + # out = copyMeshDetails(va, out, e, 6) + else: + outCorrected = out + + # create a transform for italicizing + normals = edgeNormals(out, e) + center = va + normals * stemWidth * .4 + if stemWidth > 130: + center[:, 0] = va[:, 0] * .7 + center[:,0] * .3 + centerSkew = skewMesh(center.dot(np.array([[.97,0],[0,1]])), angle * .9) + + # apply the transform + out = outCorrected + (centerSkew - center) + out[:,1] = outCorrected[:,1] + + # make some corrections + smooth = np.ones((n,1)) * .1 + out = alignCorners(glyph, out, subsegments) + out = copyMeshDetails(skewMesh(va, angle), out, e, 7, smooth=smooth) + # grad = mapEdges(lambda a,(p,n): normalize(p-a), skewMesh(outCorrected, angle*.9), e) + # out = recompose(out, grad, e, smooth=smooth) + + out = skewMesh(out, angle * .1) + out[:,0] += xoffset + # out[:,1] = outCorrected[:,1] + out[va[:,1] == 0, 1] = 0 + gOut = meshToGlyph(out, ga) + # gOut.width *= .97 + # gOut.width += 10 + # return gOut + + # recompose the glyph into original segments + return fitGlyph(glyph, gOut, subsegments) + + +def transformFLGlyphMembers(g, m, transformAnchors = True): + # g.transform(m) + g.width = g.width * m[0] + p = m.transformPoint((0,0)) + for c in g.components: + d = m.transformPoint(c.offset) + c.offset = (d[0] - p[0], d[1] - p[1]) + if transformAnchors: + for a in g.anchors: + aa = m.transformPoint((a.x,a.y)) + a.x = aa[0] + # a.x,a.y = (aa[0] - p[0], aa[1] - p[1]) + # a.x = a.x - m[4] + + +def glyphToMesh(g): + points = [] + edges = {} + offset = 0 + for c in g.contours: + if len(c) < 2: + continue + for i,prev,next in rangePrevNext(len(c)): + points.append((c[i].points[0].x, c[i].points[0].y)) + edges[i + offset] = np.array([prev + offset, next + offset], dtype=int) + offset += len(c) + return np.array(points), edges + + +def meshToGlyph(points, g): + g1 = g.copy() + j = 0 + for c in g1.contours: + if len(c) < 2: + continue + for i in range(len(c)): + c[i].points[0].x = points[j][0] + c[i].points[0].y = points[j][1] + j += 1 + return g1 + + +def quantizeGradient(grad, book=None): + if book == None: + book = np.array([(1,0),(0,1),(0,-1),(-1,0)]) + indexArray = vq(whiten(grad), book)[0] + out = book[indexArray] + for i,v in enumerate(out): + out[i] = normalize(v) + return out + + +def findControlPointsInMesh(glyph, va, subsegments): + controlPointIndices = np.zeros((len(va),1)) + index = 0 + for i,c in enumerate(subsegments): + segmentCount = len(glyph.contours[i].segments) - 1 + for j,s in enumerate(c): + if j < segmentCount: + if glyph.contours[i].segments[j].type == "line": + controlPointIndices[index] = 1 + index += s[1] + return controlPointIndices + + +def recompose(v, grad, e, smooth=1, P=None, distance=None): + n = len(v) + if distance == None: + distance = mapEdges(lambda a,(p,n): norm(p - a), v, e) + if (P == None): + P = mP(v,e) + P += np.identity(n) * smooth + f = v.copy() + for i,(prev,next) in e.iteritems(): + f[i] = (grad[next] * distance[next] - grad[i] * distance[i]) + out = v.copy() + f += v * smooth + for i in range(len(out[0,:])): + out[:,i] = cg(P, f[:,i])[0] + return out + + +def mP(v,e): + n = len(v) + M = np.zeros((n,n)) + for i, edges in e.iteritems(): + w = -2 / float(len(edges)) + for index in edges: + M[i,index] = w + M[i,i] = 2 + return M + + +def normalize(v): + n = np.linalg.norm(v) + if n == 0: + return v + return v/n + + +def mapEdges(func,v,e,*args): + b = v.copy() + for i, edges in e.iteritems(): + b[i] = func(v[i], [v[j] for j in edges], *args) + return b + + +def getNormal(a,b,c): + "Assumes TT winding direction" + p = np.roll(normalize(b - a), 1) + n = -np.roll(normalize(c - a), 1) + p[1] *= -1 + n[1] *= -1 + # print p, n, normalize((p + n) * .5) + return normalize((p + n) * .5) + + +def edgeNormals(v,e): + "Assumes a mesh where each vertex has exactly least two edges" + return mapEdges(lambda a,(p,n) : getNormal(a,p,n),v,e) + + +def rangePrevNext(count): + c = np.arange(count,dtype=int) + r = np.vstack((c, np.roll(c, 1), np.roll(c, -1))) + return r.T + + +def skewMesh(v,angle): + slope = np.tanh([math.pi * angle / 180]) + return v.dot(np.array([[1,0],[slope,1]])) + + +def labelConnected(e): + label = 0 + labels = np.zeros((len(e),1)) + for i,(prev,next) in e.iteritems(): + labels[i] = label + if next <= i: + label += 1 + return labels + + +def copyGradDetails(a,b,e,scale=15): + n = len(a) + labels = labelConnected(e) + out = a.astype(float).copy() + for i in range(labels[-1]+1): + mask = (labels==i).flatten() + out[mask,:] = gaussian(b[mask,:], scale, mode="wrap", axis=0) + a[mask,:] - gaussian(a[mask,:], scale, mode="wrap", axis=0) + return out + + +def copyMeshDetails(va,vb,e,scale=5,smooth=.01): + gradA = mapEdges(lambda a,(p,n): normalize(p-a), va, e) + gradB = mapEdges(lambda a,(p,n): normalize(p-a), vb, e) + grad = copyGradDetails(gradA, gradB, e, scale) + grad = mapEdges(lambda a,(p,n): normalize(a), grad, e) + return recompose(vb, grad, e, smooth=smooth) + + +def condenseGlyph(glyph, scale=.8, stemWidth=185): + ga, subsegments = segmentGlyph(glyph, 25) + va, e = glyphToMesh(ga) + n = len(va) + + normals = edgeNormals(va,e) + cn = va.dot(np.array([[scale, 0],[0,1]])) + grad = mapEdges(lambda a,(p,n): normalize(p-a), cn, e) + # ograd = mapEdges(lambda a,(p,n): normalize(p-a), va, e) + + cn[:,0] -= normals[:,0] * stemWidth * .5 * (1 - scale) + out = recompose(cn, grad, e, smooth=.5) + # out = recompose(out, grad, e, smooth=.1) + out = recompose(out, grad, e, smooth=.01) + + # cornerWeights = mapEdges(lambda a,(p,n): normalize(p-a).dot(normalize(a-n)), grad, e)[:,0].reshape((-1,1)) + # smooth = np.ones((n,1)) * .1 + # smooth[cornerWeights < .6] = 10 + # + # grad2 = quantizeGradient(grad).astype(float) + # grad2 = copyGradDetails(grad, grad2, e, scale=10) + # grad2 = mapEdges(lambda a,e: normalize(a), grad2, e) + # out = recompose(out, grad2, e, smooth=smooth) + out[:,0] += 15 + out[:,1] = va[:,1] + # out = recompose(out, grad, e, smooth=.5) + gOut = meshToGlyph(out, ga) + gOut = fitGlyph(glyph, gOut, subsegments) + for i,seg in enumerate(gOut): + gOut[i].points[0].y = glyph[i].points[0].y + return gOut diff --git a/misc/pylib/fontbuild/markFeature.py b/misc/pylib/fontbuild/markFeature.py new file mode 100755 index 000000000..42cafe4c7 --- /dev/null +++ b/misc/pylib/fontbuild/markFeature.py @@ -0,0 +1,55 @@ +# Copyright 2015 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +from ufo2ft.kernFeatureWriter import KernFeatureWriter +from ufo2ft.makeotfParts import FeatureOTFCompiler + + +class RobotoFeatureCompiler(FeatureOTFCompiler): + def precompile(self): + self.overwriteFeatures = True + + def setupAnchorPairs(self): + self.anchorPairs = [ + ["top", "_marktop"], + ["bottom", "_markbottom"], + ["top_dd", "_marktop_dd"], + ["bottom_dd", "_markbottom_dd"], + ["rhotichook", "_markrhotichook"], + ["top0315", "_marktop0315"], + ["parent_top", "_markparent_top"], + ["parenthesses.w1", "_markparenthesses.w1"], + ["parenthesses.w2", "_markparenthesses.w2"], + ["parenthesses.w3", "_markparenthesses.w3"]] + + self.mkmkAnchorPairs = [ + ["mkmktop", "_marktop"], + ["mkmkbottom_acc", "_markbottom"], + + # By providing a pair with accent anchor _bottom and no base anchor, + # we designate all glyphs with _bottom as accents (so that they will + # be used as base glyphs for mkmk features) without generating any + # positioning rules actually using this anchor (which is instead + # used to generate composite glyphs). This is all for consistency + # with older roboto versions. + ["", "_bottom"], + ] + + self.ligaAnchorPairs = [] + + +class RobotoKernWriter(KernFeatureWriter): + leftFeaClassRe = r"@_(.+)_L$" + rightFeaClassRe = r"@_(.+)_R$" diff --git a/misc/pylib/fontbuild/mitreGlyph.py b/misc/pylib/fontbuild/mitreGlyph.py new file mode 100644 index 000000000..d0834ed84 --- /dev/null +++ b/misc/pylib/fontbuild/mitreGlyph.py @@ -0,0 +1,111 @@ +# Copyright 2015 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +"""Mitre Glyph: + +mitreSize : Length of the segment created by the mitre. The default is 4. +maxAngle : Maximum angle in radians at which segments will be mitred. The default is .9 (about 50 degrees). + Works for both inside and outside angles + +""" + +import math +from robofab.objects.objectsRF import RPoint, RSegment +from fontbuild.convertCurves import replaceSegments + +def getTangents(contours): + tmap = [] + for c in contours: + clen = len(c) + for i in range(clen): + s = c[i] + p = s.points[-1] + ns = c[(i + 1) % clen] + ps = c[(clen + i - 1) % clen] + np = ns.points[1] if ns.type == 'curve' else ns.points[-1] + pp = s.points[2] if s.type == 'curve' else ps.points[-1] + tmap.append((pp - p, np - p)) + return tmap + +def normalizeVector(p): + m = getMagnitude(p); + if m != 0: + return p*(1/m) + else: + return RPoint(0,0) + +def getMagnitude(p): + return math.sqrt(p.x*p.x + p.y*p.y) + +def getDistance(v1,v2): + return getMagnitude(RPoint(v1.x - v2.x, v1.y - v2.y)) + +def getAngle(v1,v2): + angle = math.atan2(v1.y,v1.x) - math.atan2(v2.y,v2.x) + return (angle + (2*math.pi)) % (2*math.pi) + +def angleDiff(a,b): + return math.pi - abs((abs(a - b) % (math.pi*2)) - math.pi) + +def getAngle2(v1,v2): + return abs(angleDiff(math.atan2(v1.y, v1.x), math.atan2(v2.y, v2.x))) + +def getMitreOffset(n,v1,v2,mitreSize=4,maxAngle=.9): + + # dont mitre if segment is too short + if abs(getMagnitude(v1)) < mitreSize * 2 or abs(getMagnitude(v2)) < mitreSize * 2: + return + angle = getAngle2(v2,v1) + v1 = normalizeVector(v1) + v2 = normalizeVector(v2) + if v1.x == v2.x and v1.y == v2.y: + return + + + # only mitre corners sharper than maxAngle + if angle > maxAngle: + return + + radius = mitreSize / abs(getDistance(v1,v2)) + offset1 = RPoint(round(v1.x * radius), round(v1.y * radius)) + offset2 = RPoint(round(v2.x * radius), round(v2.y * radius)) + return offset1, offset2 + +def mitreGlyph(g,mitreSize,maxAngle): + if g == None: + return + + tangents = getTangents(g.contours) + sid = -1 + for c in g.contours: + segments = [] + needsMitring = False + for s in c: + sid += 1 + v1, v2 = tangents[sid] + off = getMitreOffset(s,v1,v2,mitreSize,maxAngle) + s1 = s.copy() + if off != None: + offset1, offset2 = off + p2 = s.points[-1] + offset2 + s2 = RSegment('line', [(p2.x, p2.y)]) + s1.points[0] += offset1 + segments.append(s1) + segments.append(s2) + needsMitring = True + else: + segments.append(s1) + if needsMitring: + replaceSegments(c, segments) diff --git a/misc/pylib/fontbuild/mix.py b/misc/pylib/fontbuild/mix.py new file mode 100644 index 000000000..5e5388b3e --- /dev/null +++ b/misc/pylib/fontbuild/mix.py @@ -0,0 +1,360 @@ +# Copyright 2015 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +from numpy import array, append +import copy +import json +from robofab.objects.objectsRF import RPoint, RGlyph +from robofab.world import OpenFont +from decomposeGlyph import decomposeGlyph + + +class FFont: + "Font wrapper for floating point operations" + + def __init__(self,f=None): + self.glyphs = {} + self.hstems = [] + self.vstems = [] + self.kerning = {} + if isinstance(f,FFont): + #self.glyphs = [g.copy() for g in f.glyphs] + for key,g in f.glyphs.iteritems(): + self.glyphs[key] = g.copy() + self.hstems = list(f.hstems) + self.vstems = list(f.vstems) + self.kerning = dict(f.kerning) + elif f != None: + self.copyFromFont(f) + + def copyFromFont(self, f): + for g in f: + self.glyphs[g.name] = FGlyph(g) + self.hstems = [s for s in f.info.postscriptStemSnapH] + self.vstems = [s for s in f.info.postscriptStemSnapV] + self.kerning = f.kerning.asDict() + + + def copyToFont(self, f): + for g in f: + try: + gF = self.glyphs[g.name] + gF.copyToGlyph(g) + except: + print "Copy to glyph failed for" + g.name + f.info.postscriptStemSnapH = self.hstems + f.info.postscriptStemSnapV = self.vstems + for pair in self.kerning: + f.kerning[pair] = self.kerning[pair] + + def getGlyph(self, gname): + try: + return self.glyphs[gname] + except: + return None + + def setGlyph(self, gname, glyph): + self.glyphs[gname] = glyph + + def addDiff(self,b,c): + newFont = FFont(self) + for key,g in newFont.glyphs.iteritems(): + gB = b.getGlyph(key) + gC = c.getGlyph(key) + try: + newFont.glyphs[key] = g.addDiff(gB,gC) + except: + print "Add diff failed for '%s'" %key + return newFont + +class FGlyph: + "provides a temporary floating point compatible glyph data structure" + + def __init__(self, g=None): + self.contours = [] + self.width = 0. + self.components = [] + self.anchors = [] + if g != None: + self.copyFromGlyph(g) + + def copyFromGlyph(self,g): + self.name = g.name + valuesX = [] + valuesY = [] + self.width = len(valuesX) + valuesX.append(g.width) + for c in g.components: + self.components.append((len(valuesX), len(valuesY))) + valuesX.append(c.scale[0]) + valuesY.append(c.scale[1]) + valuesX.append(c.offset[0]) + valuesY.append(c.offset[1]) + + for a in g.anchors: + self.anchors.append((len(valuesX), len(valuesY))) + valuesX.append(a.x) + valuesY.append(a.y) + + for i in range(len(g)): + self.contours.append([]) + for j in range (len(g[i].points)): + self.contours[i].append((len(valuesX), len(valuesY))) + valuesX.append(g[i].points[j].x) + valuesY.append(g[i].points[j].y) + + self.dataX = array(valuesX, dtype=float) + self.dataY = array(valuesY, dtype=float) + + def copyToGlyph(self,g): + g.width = self._derefX(self.width) + if len(g.components) == len(self.components): + for i in range(len(self.components)): + g.components[i].scale = (self._derefX(self.components[i][0] + 0, asInt=False), + self._derefY(self.components[i][1] + 0, asInt=False)) + g.components[i].offset = (self._derefX(self.components[i][0] + 1), + self._derefY(self.components[i][1] + 1)) + if len(g.anchors) == len(self.anchors): + for i in range(len(self.anchors)): + g.anchors[i].x = self._derefX( self.anchors[i][0]) + g.anchors[i].y = self._derefY( self.anchors[i][1]) + for i in range(len(g)) : + for j in range (len(g[i].points)): + g[i].points[j].x = self._derefX(self.contours[i][j][0]) + g[i].points[j].y = self._derefY(self.contours[i][j][1]) + + def isCompatible(self, g): + return (len(self.dataX) == len(g.dataX) and + len(self.dataY) == len(g.dataY) and + len(g.contours) == len(self.contours)) + + def __add__(self,g): + if self.isCompatible(g): + newGlyph = self.copy() + newGlyph.dataX = self.dataX + g.dataX + newGlyph.dataY = self.dataY + g.dataY + return newGlyph + else: + print "Add failed for '%s'" %(self.name) + raise Exception + + def __sub__(self,g): + if self.isCompatible(g): + newGlyph = self.copy() + newGlyph.dataX = self.dataX - g.dataX + newGlyph.dataY = self.dataY - g.dataY + return newGlyph + else: + print "Subtract failed for '%s'" %(self.name) + raise Exception + + def __mul__(self,scalar): + newGlyph = self.copy() + newGlyph.dataX = self.dataX * scalar + newGlyph.dataY = self.dataY * scalar + return newGlyph + + def scaleX(self,scalar): + newGlyph = self.copy() + if len(self.dataX) > 0: + newGlyph.dataX = self.dataX * scalar + for i in range(len(newGlyph.components)): + newGlyph.dataX[newGlyph.components[i][0]] = self.dataX[newGlyph.components[i][0]] + return newGlyph + + def shift(self,ammount): + newGlyph = self.copy() + newGlyph.dataX = self.dataX + ammount + for i in range(len(newGlyph.components)): + newGlyph.dataX[newGlyph.components[i][0]] = self.dataX[newGlyph.components[i][0]] + return newGlyph + + def interp(self, g, v): + gF = self.copy() + if not self.isCompatible(g): + print "Interpolate failed for '%s'; outlines incompatible" %(self.name) + raise Exception + + gF.dataX += (g.dataX - gF.dataX) * v.x + gF.dataY += (g.dataY - gF.dataY) * v.y + return gF + + def copy(self): + ng = FGlyph() + ng.contours = list(self.contours) + ng.width = self.width + ng.components = list(self.components) + ng.anchors = list(self.anchors) + ng.dataX = self.dataX.copy() + ng.dataY = self.dataY.copy() + ng.name = self.name + return ng + + def _derefX(self,id, asInt=True): + val = self.dataX[id] + return int(round(val)) if asInt else val + + def _derefY(self,id, asInt=True): + val = self.dataY[id] + return int(round(val)) if asInt else val + + def addDiff(self,gB,gC): + newGlyph = self + (gB - gC) + return newGlyph + + + +class Master: + + def __init__(self, font=None, v=0, kernlist=None, overlay=None): + if isinstance(font, FFont): + self.font = None + self.ffont = font + elif isinstance(font,str): + self.openFont(font,overlay) + elif isinstance(font,Mix): + self.font = font + else: + self.font = font + self.ffont = FFont(font) + if isinstance(v,float) or isinstance(v,int): + self.v = RPoint(v, v) + else: + self.v = v + if kernlist != None: + kerns = [i.strip().split() for i in open(kernlist).readlines()] + + self.kernlist = [{'left':k[0], 'right':k[1], 'value': k[2]} + for k in kerns + if not k[0].startswith("#") + and not k[0] == ""] + #TODO implement class based kerning / external kerning file + + def openFont(self, path, overlayPath=None): + self.font = OpenFont(path) + for g in self.font: + size = len(g) + csize = len(g.components) + if (size > 0 and csize > 0): + decomposeGlyph(self.font, self.font[g.name]) + + if overlayPath != None: + overlayFont = OpenFont(overlayPath) + font = self.font + for overlayGlyph in overlayFont: + font.insertGlyph(overlayGlyph) + + self.ffont = FFont(self.font) + + +class Mix: + def __init__(self,masters,v): + self.masters = masters + if isinstance(v,float) or isinstance(v,int): + self.v = RPoint(v,v) + else: + self.v = v + + def getFGlyph(self, master, gname): + if isinstance(master.font, Mix): + return font.mixGlyphs(gname) + return master.ffont.getGlyph(gname) + + def getGlyphMasters(self,gname): + masters = self.masters + if len(masters) <= 2: + return self.getFGlyph(masters[0], gname), self.getFGlyph(masters[-1], gname) + + def generateFFont(self): + ffont = FFont(self.masters[0].ffont) + for key,g in ffont.glyphs.iteritems(): + ffont.glyphs[key] = self.mixGlyphs(key) + ffont.kerning = self.mixKerns() + return ffont + + def generateFont(self, baseFont): + newFont = baseFont.copy() + #self.mixStems(newFont) todo _ fix stems code + for g in newFont: + gF = self.mixGlyphs(g.name) + if gF == None: + g.mark = True + elif isinstance(gF, RGlyph): + newFont[g.name] = gF.copy() + else: + gF.copyToGlyph(g) + + newFont.kerning.clear() + newFont.kerning.update(self.mixKerns() or {}) + return newFont + + def mixGlyphs(self,gname): + gA,gB = self.getGlyphMasters(gname) + try: + return gA.interp(gB,self.v) + except: + print "mixglyph failed for %s" %(gname) + if gA != None: + return gA.copy() + + def getKerning(self, master): + if isinstance(master.font, Mix): + return master.font.mixKerns() + return master.ffont.kerning + + def mixKerns(self): + masters = self.masters + kA, kB = self.getKerning(masters[0]), self.getKerning(masters[-1]) + return interpolateKerns(kA, kB, self.v) + + +def narrowFLGlyph(g, gThin, factor=.75): + gF = FGlyph(g) + if not isinstance(gThin,FGlyph): + gThin = FGlyph(gThin) + gCondensed = gThin.scaleX(factor) + try: + gNarrow = gF + (gCondensed - gThin) + gNarrow.copyToGlyph(g) + except: + print "No dice for: " + g.name + +def interpolate(a,b,v,e=0): + if e == 0: + return a+(b-a)*v + qe = (b-a)*v*v*v + a #cubic easing + le = a+(b-a)*v # linear easing + return le + (qe-le) * e + +def interpolateKerns(kA, kB, v): + # to yield correct kerning for Roboto output, we must emulate the behavior + # of old versions of this code; namely, take the kerning values of the first + # master instead of actually interpolating. + # old code: + # https://github.com/google/roboto/blob/7f083ac31241cc86d019ea6227fa508b9fcf39a6/scripts/lib/fontbuild/mix.py + # bug: + # https://github.com/google/roboto/issues/213 + # return dict(kA) + + kerns = {} + for pair, val in kA.items(): + kerns[pair] = interpolate(val, kB.get(pair, 0), v.x) + for pair, val in kB.items(): + lerped_val = interpolate(val, kA.get(pair, 0), 1 - v.x) + if pair in kerns: + assert abs(kerns[pair] - lerped_val) < 1e-6 + else: + kerns[pair] = lerped_val + return kerns -- cgit v1.2.3