summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorRasmus Andersson <rasmus@notion.se>2019-10-23 03:00:58 +0300
committerRasmus Andersson <rasmus@notion.se>2019-10-23 03:00:58 +0300
commitaa7ad2d7a0e255d4052c4999ec62d88c699586ac (patch)
tree54e8c771f6b0c5c09cedc3da4d9173c48968e580
parent9c444dededcb0787d562072a08c909ec463ea4b7 (diff)
downloadinter-aa7ad2d7a0e255d4052c4999ec62d88c699586ac.tar.xz
fontbuild: remove use of fontmake, simplifying things.
-rwxr-xr-xmisc/fontbuild429
-rw-r--r--misc/fontbuildlib/__init__.py1
-rw-r--r--misc/fontbuildlib/builder.py149
-rw-r--r--misc/fontbuildlib/glyph.py86
-rw-r--r--misc/fontbuildlib/info.py148
-rw-r--r--misc/fontbuildlib/name.py143
-rw-r--r--misc/fontbuildlib/util.py29
-rw-r--r--misc/fontbuildlib/version.py0
-rw-r--r--requirements.txt18
9 files changed, 613 insertions, 390 deletions
diff --git a/misc/fontbuild b/misc/fontbuild
index 461ec7c20..61b599067 100755
--- a/misc/fontbuild
+++ b/misc/fontbuild
@@ -1,43 +1,30 @@
#!/usr/bin/env python
from __future__ import print_function, absolute_import
-
-import sys, os
+import sys
+import os
from os.path import dirname, basename, abspath, relpath, join as pjoin
sys.path.append(abspath(pjoin(dirname(__file__), 'tools')))
-from common import BASEDIR, VENVDIR, getGitHash, getVersion, execproc
+from common import BASEDIR, execproc
from collections import OrderedDict
import argparse
-import datetime
-import errno
import glyphsLib
import logging
import re
import signal
import subprocess
-import ufo2ft
-import font_names
-
-from functools import partial
-from fontmake.font_project import FontProject
-from defcon import Font
-from fontTools import designspaceLib
-from fontTools import varLib
-from fontTools.misc.transform import Transform
-from fontTools.pens.transformPen import TransformPen
-from fontTools.pens.reverseContourPen import ReverseContourPen
-from glyphsLib.interpolation import apply_instance_data
+
+from fontTools.designspaceLib import DesignSpaceDocument
from mutatorMath.ufo.document import DesignSpaceDocumentReader
from multiprocessing import Process, Queue
-from ufo2ft.filters.removeOverlaps import RemoveOverlapsFilter
-from ufo2ft import CFFOptimization
-
-log = logging.getLogger(__name__)
-stripItalic_re = re.compile(r'(?:^|\b)italic\b|italic$', re.I | re.U)
+from glyphsLib.interpolation import apply_instance_data
+from fontbuildlib import FontBuilder
+from fontbuildlib.util import mkdirs, loadTTFont
+from fontbuildlib.info import setFontInfo, updateFontVersion
+from fontbuildlib.name import setFamilyName, removeWhitespaceFromStyles
-def stripItalic(name):
- return stripItalic_re.sub('', name.strip())
+log = logging.getLogger(__name__)
def sighandler(signum, frame):
@@ -46,263 +33,11 @@ def sighandler(signum, frame):
sys.exit(1)
-def mkdirs(path):
- try:
- os.makedirs(path)
- except OSError as e:
- if e.errno != errno.EEXIST:
- raise # raises the error again
-
-
def fatal(msg):
print(sys.argv[0] + ': ' + msg, file=sys.stderr)
sys.exit(1)
-
-def composedGlyphIsNonTrivial(g):
- # A non-trivial glyph is one that uses reflecting component transformations.
- if g.components and len(g.components) > 0:
- for c in g.components:
- # has non-trivial transformation? (i.e. scaled)
- # Example of optimally trivial transformation:
- # (1, 0, 0, 1, 0, 0) no scale or offset
- # Example of scaled transformation matrix:
- # (-1.0, 0, 0.3311, 1, 1464.0, 0) flipped x axis, sheered and offset
- #
- xScale = c.transformation[0]
- yScale = c.transformation[3]
- # If glyph is reflected along x or y axes, it won't slant well.
- if xScale < 0 or yScale < 0:
- return True
-
- return False
-
-
-# Directives are glyph-specific post-processing directives for the compiler.
-# A directive is added to the "note" section of a glyph and takes the
-# following form:
-#
-# !post:DIRECTIVE
-#
-# Where DIRECTIVE is the name of a known directive.
-# This string can appear anywhere in the glyph note.
-# Directives are _not_ case sensitive but normalized by str.lower(), meaning
-# that e.g. "removeoverlap" == "RemoveOverlap" == "REMOVEOVERLAP".
-#
-knownDirectives = set([
- 'removeoverlap', # applies overlap removal (boolean union)
-])
-
-
-findDirectiveRegEx = re.compile(r'\!post:([^ ]+)', re.I | re.U)
-
-def findGlyphDirectives(g): # -> set<string> | None
- directives = set()
- if g.note and len(g.note) > 0:
- for directive in findDirectiveRegEx.findall(g.note):
- directive = directive.lower()
- if directive in knownDirectives:
- directives.add(directive)
- else:
- print(
- 'unknown glyph directive !post:%s in glyph %s' % (directive, g.name),
- file=sys.stderr
- )
- return directives
-
-
-
-def deep_copy_contours(ufo, parent, component, transformation):
- """Copy contours from component to parent, including nested components."""
- for nested in component.components:
- deep_copy_contours(
- ufo, parent, ufo[nested.baseGlyph],
- transformation.transform(nested.transformation))
- if component != parent:
- pen = TransformPen(parent.getPen(), transformation)
- # if the transformation has a negative determinant, it will reverse
- # the contour direction of the component
- xx, xy, yx, yy = transformation[:4]
- if xx*yy - xy*yx < 0:
- pen = ReverseContourPen(pen)
- component.draw(pen)
-
-
-
-def decompose_glyphs(ufos, glyphNamesToDecompose):
- for ufo in ufos:
- for glyphname in glyphNamesToDecompose:
- glyph = ufo[glyphname]
- deep_copy_contours(ufo, glyph, glyph, Transform())
- glyph.clearComponents()
-
-
-
-# subclass of fontmake.FontProject that
-# - patches version metadata
-# - decomposes certain glyphs
-# - removes overlaps of certain glyphs
-#
-class VarFontProject(FontProject):
- def __init__(self, compact_style_names=False, *args, **kwargs):
- super(VarFontProject, self).__init__(*args, **kwargs)
- self.compact_style_names = compact_style_names
-
-
- # override FontProject._load_designspace_sources
- def _load_designspace_sources(self, designspace):
- designspace = FontProject._load_designspace_sources(designspace)
- masters = [s.font for s in designspace.sources] # list of UFO font objects
-
- # Update the default source's full name to not include style name
- defaultFont = designspace.default.font
- defaultFont.info.openTypeNameCompatibleFullName = defaultFont.info.familyName
-
- for ufo in masters:
- # patch style name if --compact-style-names is set
- if self.compact_style_names:
- collapseFontStyleName(ufo)
- # update font version
- updateFontVersion(ufo, isVF=True)
-
- # find glyphs subject to decomposition and/or overlap removal
- glyphNamesToDecompose = set() # glyph names
- glyphsToRemoveOverlaps = set() # glyph names
- for ufo in masters:
- for g in ufo:
- directives = findGlyphDirectives(g)
- if g.components and composedGlyphIsNonTrivial(g):
- glyphNamesToDecompose.add(g.name)
- if 'removeoverlap' in directives:
- if g.components and len(g.components) > 0:
- glyphNamesToDecompose.add(g.name)
- glyphsToRemoveOverlaps.add(g)
-
- # decompose
- if log.isEnabledFor(logging.INFO):
- log.info('Decomposing glyphs:\n %s', "\n ".join(glyphNamesToDecompose))
- decompose_glyphs(masters, glyphNamesToDecompose)
-
- # remove overlaps
- if len(glyphsToRemoveOverlaps) > 0:
- rmoverlapFilter = RemoveOverlapsFilter(backend='pathops')
- rmoverlapFilter.start()
- if log.isEnabledFor(logging.INFO):
- log.info(
- 'Removing overlaps in glyphs:\n %s',
- "\n ".join(set([g.name for g in glyphsToRemoveOverlaps])),
- )
- for g in glyphsToRemoveOverlaps:
- rmoverlapFilter.filter(g)
-
- # handle control back to fontmake
- return designspace
-
-
-
-def updateFontVersion(font, dummy=False, isVF=False):
- version = getVersion()
- buildtag = getGitHash()
- now = datetime.datetime.utcnow()
- if dummy:
- version = "1.0"
- buildtag = "src"
- now = datetime.datetime(2016, 1, 1, 0, 0, 0, 0)
- versionMajor, versionMinor = [int(num) for num in version.split(".")]
- font.info.version = version
- font.info.versionMajor = versionMajor
- font.info.versionMinor = versionMinor
- font.info.woffMajorVersion = versionMajor
- font.info.woffMinorVersion = versionMinor
- font.info.year = now.year
- font.info.openTypeNameVersion = "Version %d.%03d;git-%s" % (versionMajor, versionMinor, buildtag)
- psFamily = re.sub(r'\s', '', font.info.familyName)
- if isVF:
- font.info.openTypeNameUniqueID = "%s:VF:%d:%s" % (psFamily, now.year, buildtag)
- else:
- psStyle = re.sub(r'\s', '', font.info.styleName)
- font.info.openTypeNameUniqueID = "%s-%s:%d:%s" % (psFamily, psStyle, now.year, buildtag)
- font.info.openTypeHeadCreated = now.strftime("%Y/%m/%d %H:%M:%S")
-
-
-# setFontInfo patches font.info
-#
-def setFontInfo(font, weight):
- #
- # For UFO3 names, see
- # https://github.com/unified-font-object/ufo-spec/blob/gh-pages/versions/
- # ufo3/fontinfo.plist.md
- # For OpenType NAME table IDs, see
- # https://docs.microsoft.com/en-us/typography/opentype/spec/name#name-ids
-
- # Add " BETA" to light weights
- if weight < 400:
- font.info.styleName = font.info.styleName + " BETA"
-
- family = font.info.familyName # i.e. "Inter"
- style = font.info.styleName # e.g. "Medium Italic"
-
- # Update italicAngle
- isitalic = style.find("Italic") != -1
- if isitalic:
- font.info.italicAngle = float('%.8g' % font.info.italicAngle)
- else:
- font.info.italicAngle = 0 # avoid "-0.0" value in UFO
-
- # weight
- font.info.openTypeOS2WeightClass = weight
-
- # version (dummy)
- updateFontVersion(font, dummy=True)
-
- # Names
- family_nosp = re.sub(r'\s', '', family)
- style_nosp = re.sub(r'\s', '', style)
- font.info.macintoshFONDName = "%s %s" % (family_nosp, style_nosp)
- font.info.postscriptFontName = "%s-%s" % (family_nosp, style_nosp)
-
- # name ID 16 "Typographic Family name"
- font.info.openTypeNamePreferredFamilyName = family
-
- # name ID 17 "Typographic Subfamily name"
- font.info.openTypeNamePreferredSubfamilyName = style
-
- # name ID 1 "Family name" (legacy, but required)
- # Restriction:
- # "shared among at most four fonts that differ only in weight or style"
- # So we map as follows:
- # - Regular => "Family", ("regular" | "italic" | "bold" | "bold italic")
- # - Medium => "Family Medium", ("regular" | "italic")
- # - Black => "Family Black", ("regular" | "italic")
- # and so on.
- subfamily = stripItalic(style).strip() # "A Italic" => "A", "A" => "A"
- if len(subfamily) == 0:
- subfamily = "Regular"
- subfamily_lc = subfamily.lower()
- if subfamily_lc == "regular" or subfamily_lc == "bold":
- font.info.styleMapFamilyName = family
- # name ID 2 "Subfamily name" (legacy, but required)
- # Value must be one of: "regular", "italic", "bold", "bold italic"
- if subfamily_lc == "regular":
- if isitalic:
- font.info.styleMapStyleName = "italic"
- else:
- font.info.styleMapStyleName = "regular"
- else: # bold
- if isitalic:
- font.info.styleMapStyleName = "bold italic"
- else:
- font.info.styleMapStyleName = "bold"
- else:
- font.info.styleMapFamilyName = (family + ' ' + subfamily).strip()
- # name ID 2 "Subfamily name" (legacy, but required)
- if isitalic:
- font.info.styleMapStyleName = "italic"
- else:
- font.info.styleMapStyleName = "regular"
-
-
def collapseFontStyleName(font):
# collapse whitespace in style name. i.e. "Semi Bold Italic" -> "SemiBoldItalic"
font.info.styleName = re.sub(r'\s', '', font.info.styleName)
@@ -365,7 +100,8 @@ class Main(object):
# parse CLI arguments
args = argparser.parse_args(argv[1:i])
- logFormat = '%(funcName)s: %(message)s'
+
+ # log config
if args.quiet:
self.quiet = True
if args.debug:
@@ -373,14 +109,13 @@ class Main(object):
if args.verbose:
fatal("--quiet and --verbose are mutually exclusive arguments")
elif args.debug:
- logging.basicConfig(level=logging.DEBUG, format=logFormat)
+ logging.basicConfig(level=logging.DEBUG, format='%(funcName)s: %(message)s')
self.logLevelName = 'DEBUG'
elif args.verbose:
- logging.basicConfig(level=logging.INFO, format=logFormat)
+ logging.basicConfig(level=logging.INFO, format='%(funcName)s: %(message)s')
self.logLevelName = 'INFO'
else:
- logFormat = '%(message)s'
- logging.basicConfig(level=logging.WARNING, format=logFormat)
+ logging.basicConfig(level=logging.WARNING, format='%(message)s')
self.logLevelName = 'WARNING'
if args.chdir:
@@ -404,49 +139,22 @@ class Main(object):
argparser.add_argument('-o', '--output', metavar='<fontfile>',
help='Output font file')
- argparser.add_argument('--compact-style-names', action='store_true',
- help="Produce font files with style names that doesn't contain spaces. "\
- "E.g. \"SemiBoldItalic\" instead of \"Semi Bold Italic\"")
-
args = argparser.parse_args(argv)
# decide output filename (or check user-provided name)
outfilename = args.output
if outfilename is None or outfilename == '':
- outfilename = os.path.splitext(basename(args.srcfile))[0] + '.var.otf'
- log.info('setting --output %r' % outfilename)
+ outfilename = pjoin(
+ dirname(args.srcfile),
+ os.path.splitext(basename(args.srcfile))[0] + '.otf'
+ )
+ log.debug('setting --output %r' % outfilename)
mkdirs(dirname(outfilename))
- project = VarFontProject(
- verbose=self.logLevelName,
- compact_style_names=args.compact_style_names,
- )
- project.run_from_designspace(
- args.srcfile,
- interpolate=False,
- masters_as_instances=False,
- use_production_names=True,
- round_instances=True,
- output_path=outfilename,
- output=["variable"], # "variable-cff2" in the future
- optimize_cff=CFFOptimization.SUBROUTINIZE,
- overlaps_backend='pathops', # use Skia's pathops
- )
-
- # Rename fullName record to familyName (VF only)
- # Note: Even though we set openTypeNameCompatibleFullName it seems that the fullName
- # record is still computed by fonttools, so we override it here.
- font = font_names.loadFont(outfilename)
- try:
- familyName = font_names.getFamilyName(font)
- font_names.setFullName(font, familyName)
- font.save(outfilename)
- finally:
- font.close()
+ FontBuilder().buildVariable(args.srcfile, outfilename)
self.log("write %s" % outfilename)
-
# Note: we can't run ots-sanitize on the generated file as OTS
# currently doesn't support variable fonts.
@@ -461,80 +169,40 @@ class Main(object):
help='Source file (.ufo file)')
argparser.add_argument('-o', '--output', metavar='<fontfile>',
- help='Output font file (.otf, .ttf or .ufo)')
+ help='Output font file (.otf or .ttf)')
argparser.add_argument('--validate', action='store_true',
help='Enable ufoLib validation on reading/writing UFO files')
- argparser.add_argument('--compact-style-names', action='store_true',
- help="Produce font files with style names that doesn't contain spaces. "\
- "E.g. \"SemiBoldItalic\" instead of \"Semi Bold Italic\"")
-
args = argparser.parse_args(argv)
- ext_to_format = {
- '.ufo': 'ufo',
- '.otf': 'otf',
- '.ttf': 'ttf',
-
- # non-filename mapping targets: (kept for completeness)
- # 'ttf-interpolatable',
- # 'variable',
- }
+ # write an OTF/CFF (or a TTF if false)
+ cff = True
# decide output filename
outfilename = args.output
if outfilename is None or outfilename == '':
- outfilename = os.path.splitext(basename(args.srcfile))[0] + '.otf'
- log.info('setting --output %r' % outfilename)
-
- # build formats list from filename extension
- formats = []
- # for outfilename in args.outputs:
- ext = os.path.splitext(outfilename)[1]
- ext_lc = ext.lower()
- if ext_lc in ext_to_format:
- formats.append(ext_to_format[ext_lc])
+ outfilename = pjoin(
+ dirname(args.srcfile),
+ os.path.splitext(basename(args.srcfile))[0] + '.otf'
+ )
+ log.debug('setting --output %r' % outfilename)
else:
- fatal('Unsupported output format %s' % ext)
+ fext = os.path.splitext(outfilename)[1].lower()
+ if fext == ".ttf":
+ cff = False
+ elif fext != ".otf":
+ raise Exception('invalid file format %r (expected ".otf" or ".ttf")' % fext)
# temp file to write to
tmpfilename = pjoin(self.tmpdir, basename(outfilename))
mkdirs(self.tmpdir)
- project = FontProject(verbose=self.logLevelName, validate_ufo=args.validate)
-
- ufo = Font(args.srcfile)
-
- # patch style name if --compact-style-names is set
- if args.compact_style_names:
- collapseFontStyleName(ufo)
-
- # update version to actual, real version.
- # must come after collapseFontStyleName or any other call to setFontInfo.
- updateFontVersion(ufo)
-
- # if outfile is a ufo, simply move it to outfilename instead
- # of running ots-sanitize.
- output_path = tmpfilename
- if formats[0] == 'ufo':
- output_path = outfilename
-
- # run fontmake to produce OTF/TTF file at tmpfilename
- project.run_from_ufos(
- [ ufo ],
- output_path=output_path,
- output=formats,
- use_production_names=True,
- optimize_cff=CFFOptimization.SUBROUTINIZE, # NONE
- overlaps_backend='pathops', # use Skia's pathops
- remove_overlaps=True,
- )
+ # build OTF or TTF file from UFO
+ FontBuilder().buildStatic(args.srcfile, tmpfilename, cff)
- if output_path == tmpfilename:
- # Run ots-sanitize on produced OTF/TTF file and write sanitized version
- # to outfilename
- self._ots_sanitize(output_path, outfilename)
+ # Run ots-sanitize on produced OTF/TTF file and write sanitized version to outfilename
+ self._ots_sanitize(tmpfilename, outfilename)
def _ots_sanitize(self, tmpfilename, outfilename):
@@ -680,6 +348,8 @@ class Main(object):
if outdir is None:
outdir = dirname(args.glyphsfile)
+ # TODO: Move into fontbuildlib
+
# files
master_dir = outdir
glyphsfile = args.glyphsfile
@@ -808,7 +478,7 @@ class Main(object):
verbose=True
)
- designspace = designspaceLib.DesignSpaceDocument()
+ designspace = DesignSpaceDocument()
designspace.read(designspace_file)
# Generate UFOs for instances
@@ -923,23 +593,24 @@ class Main(object):
infile = args.input
outfile = args.output or infile
- font = font_names.loadFont(infile)
+ font = loadTTFont(infile)
editCount = 0
try:
if args.family:
editCount += 1
- font_names.setFamilyName(font, args.family)
+ setFamilyName(font, args.family)
if args.compact_style:
editCount += 1
- font_names.removeWhitespaceFromStyles(font)
+ removeWhitespaceFromStyles(font)
- if editCount > 0:
- font.save(outfile)
- else:
+ if editCount == 0:
print("no rename options provided", file=sys.stderr)
- argparser.print_usage(sys.stderr)
+ argparser.print_help(sys.stderr)
sys.exit(1)
+ return
+
+ font.save(outfile)
finally:
font.close()
diff --git a/misc/fontbuildlib/__init__.py b/misc/fontbuildlib/__init__.py
new file mode 100644
index 000000000..7c6fca231
--- /dev/null
+++ b/misc/fontbuildlib/__init__.py
@@ -0,0 +1 @@
+from .builder import FontBuilder
diff --git a/misc/fontbuildlib/builder.py b/misc/fontbuildlib/builder.py
new file mode 100644
index 000000000..98166d5ac
--- /dev/null
+++ b/misc/fontbuildlib/builder.py
@@ -0,0 +1,149 @@
+import logging
+import ufo2ft
+from defcon import Font
+from ufo2ft.util import _LazyFontName
+from ufo2ft.filters.removeOverlaps import RemoveOverlapsFilter
+from fontTools.designspaceLib import DesignSpaceDocument
+from .name import getFamilyName, setFullName
+from .info import updateFontVersion
+from .glyph import findGlyphDirectives, composedGlyphIsTrivial, decomposeGlyphs
+
+log = logging.getLogger(__name__)
+
+
+class FontBuilder:
+ # def __init__(self, *args, **kwargs)
+
+ def buildStatic(self,
+ ufo, # input UFO as filename string or defcon.Font object
+ outputFilename, # output filename string
+ cff=True, # true = makes CFF outlines. false = makes TTF outlines.
+ **kwargs, # passed along to ufo2ft.compile*()
+ ):
+ if isinstance(ufo, str):
+ ufo = Font(ufo)
+
+ # update version to actual, real version. Must come after any call to setFontInfo.
+ updateFontVersion(ufo, dummy=False, isVF=False)
+
+ compilerOptions = dict(
+ useProductionNames=True,
+ inplace=True, # avoid extra copy
+ removeOverlaps=True,
+ overlapsBackend='pathops', # use Skia's pathops
+ )
+
+ log.info("compiling %s -> %s (%s)", _LazyFontName(ufo), outputFilename,
+ "OTF/CFF-2" if cff else "TTF")
+
+ if cff:
+ font = ufo2ft.compileOTF(ufo, **compilerOptions)
+ else: # ttf
+ font = ufo2ft.compileTTF(ufo, **compilerOptions)
+
+ log.debug("writing %s", outputFilename)
+ font.save(outputFilename)
+
+
+
+ def buildVariable(self,
+ designspace, # designspace filename string or DesignSpaceDocument object
+ outputFilename, # output filename string
+ cff=False, # if true, builds CFF-2 font, else TTF
+ **kwargs, # passed along to ufo2ft.compileVariable*()
+ ):
+ designspace = self._loadDesignspace(designspace)
+
+ # check in the designspace's <lib> element if user supplied a custom featureWriters
+ # configuration; if so, use that for all the UFOs built from this designspace.
+ featureWriters = None
+ if ufo2ft.featureWriters.FEATURE_WRITERS_KEY in designspace.lib:
+ featureWriters = ufo2ft.featureWriters.loadFeatureWriters(designspace)
+
+ compilerOptions = dict(
+ useProductionNames=True,
+ featureWriters=featureWriters,
+ inplace=True, # avoid extra copy
+ **kwargs
+ )
+
+ if log.isEnabledFor(logging.INFO):
+ log.info("compiling %s -> %s (%s)", designspace.path, outputFilename,
+ "OTF/CFF-2" if cff else "TTF")
+
+ if cff:
+ font = ufo2ft.compileVariableCFF2(designspace, **compilerOptions)
+ else:
+ font = ufo2ft.compileVariableTTF(designspace, **compilerOptions)
+
+ # Rename fullName record to familyName (VF only).
+ # Note: Even though we set openTypeNameCompatibleFullName it seems that the fullName
+ # record is still computed by fonttools, so we override it here.
+ setFullName(font, getFamilyName(font))
+
+ log.debug("writing %s", outputFilename)
+ font.save(outputFilename)
+
+
+
+ @staticmethod
+ def _loadDesignspace(designspace):
+ log.info("loading designspace sources")
+ if isinstance(designspace, str):
+ designspace = DesignSpaceDocument.fromfile(designspace)
+ else:
+ # copy that we can mess with
+ designspace = DesignSpaceDocument.fromfile(designspace.path)
+
+ masters = designspace.loadSourceFonts(opener=Font)
+ # masters = [s.font for s in designspace.sources] # list of UFO font objects
+
+ # Update the default source's full name to not include style name
+ defaultFont = designspace.default.font
+ defaultFont.info.openTypeNameCompatibleFullName = defaultFont.info.familyName
+
+ for ufo in masters:
+ # update font version
+ updateFontVersion(ufo, dummy=False, isVF=True)
+
+ log.info("Preprocessing glyphs")
+ # find glyphs subject to decomposition and/or overlap removal
+ # TODO: Find out why this loop is SO DAMN SLOW. It might just be so that defcon is
+ # really slow when reading glyphs. Perhaps we can sidestep defcon and just
+ # read & parse the .glif files ourselves.
+ glyphNamesToDecompose = set() # glyph names
+ glyphsToRemoveOverlaps = set() # glyph objects
+ for ufo in masters:
+ for g in ufo:
+ if g.components and not composedGlyphIsTrivial(g):
+ glyphNamesToDecompose.add(g.name)
+ if 'removeoverlap' in findGlyphDirectives(g.note):
+ if g.components and len(g.components) > 0:
+ glyphNamesToDecompose.add(g.name)
+ glyphsToRemoveOverlaps.add(g)
+
+ # decompose
+ if glyphNamesToDecompose:
+ if log.isEnabledFor(logging.DEBUG):
+ log.debug('Decomposing glyphs:\n %s', "\n ".join(glyphNamesToDecompose))
+ elif log.isEnabledFor(logging.INFO):
+ log.info('Decomposing %d glyphs', len(glyphNamesToDecompose))
+ decomposeGlyphs(masters, glyphNamesToDecompose)
+
+ # remove overlaps
+ if glyphsToRemoveOverlaps:
+ rmoverlapFilter = RemoveOverlapsFilter(backend='pathops')
+ rmoverlapFilter.start()
+ if log.isEnabledFor(logging.DEBUG):
+ log.debug(
+ 'Removing overlaps in glyphs:\n %s',
+ "\n ".join(set([g.name for g in glyphsToRemoveOverlaps])),
+ )
+ elif log.isEnabledFor(logging.INFO):
+ log.info('Removing overlaps in %d glyphs', len(glyphsToRemoveOverlaps))
+ for g in glyphsToRemoveOverlaps:
+ rmoverlapFilter.filter(g)
+
+ # handle control back to fontmake
+ return designspace
+
diff --git a/misc/fontbuildlib/glyph.py b/misc/fontbuildlib/glyph.py
new file mode 100644
index 000000000..734b881e0
--- /dev/null
+++ b/misc/fontbuildlib/glyph.py
@@ -0,0 +1,86 @@
+import re
+from fontTools.pens.transformPen import TransformPen
+from fontTools.misc.transform import Transform
+from fontTools.pens.reverseContourPen import ReverseContourPen
+
+# Directives are glyph-specific post-processing directives for the compiler.
+# A directive is added to the "note" section of a glyph and takes the
+# following form:
+#
+# !post:DIRECTIVE
+#
+# Where DIRECTIVE is the name of a known directive.
+# This string can appear anywhere in the glyph note.
+# Directives are _not_ case sensitive but normalized by str.lower(), meaning
+# that e.g. "removeoverlap" == "RemoveOverlap" == "REMOVEOVERLAP".
+#
+knownDirectives = set([
+ 'removeoverlap', # applies overlap removal (boolean union)
+])
+
+
+_findDirectiveRegEx = re.compile(r'\!post:([^ ]+)', re.I | re.U)
+
+
+def findGlyphDirectives(string): # -> set<string> | None
+ directives = set()
+ if string and len(string) > 0:
+ for directive in _findDirectiveRegEx.findall(string):
+ directive = directive.lower()
+ if directive in knownDirectives:
+ directives.add(directive)
+ else:
+ print(
+ 'unknown glyph directive !post:%s in glyph %s' % (directive, g.name),
+ file=sys.stderr
+ )
+ return directives
+
+
+
+def composedGlyphIsTrivial(g):
+ # A trivial glyph is one that does not use components or where component transformation
+ # does not include mirroring (i.e. "flipped").
+ if g.components and len(g.components) > 0:
+ for c in g.components:
+ # has non-trivial transformation? (i.e. scaled)
+ # Example of optimally trivial transformation:
+ # (1, 0, 0, 1, 0, 0) no scale or offset
+ # Example of scaled transformation matrix:
+ # (-1.0, 0, 0.3311, 1, 1464.0, 0) flipped x axis, sheered and offset
+ #
+ xScale = c.transformation[0]
+ yScale = c.transformation[3]
+ # If glyph is reflected along x or y axes, it won't slant well.
+ if xScale < 0 or yScale < 0:
+ return False
+ return True
+
+
+
+def decomposeGlyphs(ufos, glyphNamesToDecompose):
+ for ufo in ufos:
+ for glyphname in glyphNamesToDecompose:
+ glyph = ufo[glyphname]
+ _deepCopyContours(ufo, glyph, glyph, Transform())
+ glyph.clearComponents()
+
+
+
+def _deepCopyContours(ufo, parent, component, transformation):
+ """Copy contours from component to parent, including nested components."""
+ for nested in component.components:
+ _deepCopyContours(
+ ufo,
+ parent,
+ ufo[nested.baseGlyph],
+ transformation.transform(nested.transformation)
+ )
+ if component != parent:
+ pen = TransformPen(parent.getPen(), transformation)
+ # if the transformation has a negative determinant, it will reverse
+ # the contour direction of the component
+ xx, xy, yx, yy = transformation[:4]
+ if xx*yy - xy*yx < 0:
+ pen = ReverseContourPen(pen)
+ component.draw(pen) \ No newline at end of file
diff --git a/misc/fontbuildlib/info.py b/misc/fontbuildlib/info.py
new file mode 100644
index 000000000..12b59dd6b
--- /dev/null
+++ b/misc/fontbuildlib/info.py
@@ -0,0 +1,148 @@
+import subprocess
+import re
+from datetime import datetime
+from common import getGitHash, getVersion
+from .util import readTextFile, BASEDIR, pjoin
+
+
+_gitHash = None
+def getGitHash():
+ global _gitHash
+ if _gitHash is None:
+ _gitHash = ''
+ try:
+ _gitHash = subprocess.check_output(
+ ['git', '-C', BASEDIR, 'rev-parse', '--short', 'HEAD'],
+ stderr=subprocess.STDOUT,
+ **_enc_kwargs
+ ).strip()
+ except:
+ try:
+ # git rev-parse --short HEAD > githash.txt
+ _gitHash = readTextFile(pjoin(BASEDIR, 'githash.txt')).strip()
+ except:
+ pass
+ return _gitHash
+
+
+_version = None
+def getVersion():
+ global _version
+ if _version is None:
+ _version = readTextFile(pjoin(BASEDIR, 'version.txt')).strip()
+ return _version
+
+
+
+def updateFontVersion(font, dummy, isVF):
+ if dummy:
+ version = "1.0"
+ buildtag = "src"
+ now = datetime(2016, 1, 1, 0, 0, 0, 0)
+ else:
+ version = getVersion()
+ buildtag = getGitHash()
+ now = datetime.utcnow()
+ versionMajor, versionMinor = [int(num) for num in version.split(".")]
+ font.info.version = version
+ font.info.versionMajor = versionMajor
+ font.info.versionMinor = versionMinor
+ font.info.woffMajorVersion = versionMajor
+ font.info.woffMinorVersion = versionMinor
+ font.info.year = now.year
+ font.info.openTypeNameVersion = "Version %d.%03d;git-%s" % (versionMajor, versionMinor, buildtag)
+ psFamily = re.sub(r'\s', '', font.info.familyName)
+ if isVF:
+ font.info.openTypeNameUniqueID = "%s:VF:%d:%s" % (psFamily, now.year, buildtag)
+ else:
+ psStyle = re.sub(r'\s', '', font.info.styleName)
+ font.info.openTypeNameUniqueID = "%s-%s:%d:%s" % (psFamily, psStyle, now.year, buildtag)
+ font.info.openTypeHeadCreated = now.strftime("%Y/%m/%d %H:%M:%S")
+
+
+
+# setFontInfo patches font.info
+def setFontInfo(font, weight=None):
+ #
+ # For UFO3 names, see
+ # https://github.com/unified-font-object/ufo-spec/blob/gh-pages/versions/
+ # ufo3/fontinfo.plist.md
+ # For OpenType NAME table IDs, see
+ # https://docs.microsoft.com/en-us/typography/opentype/spec/name#name-ids
+
+ if weight is None:
+ weight = font.info.openTypeOS2WeightClass
+
+ # Add " BETA" to light weights
+ if weight < 400:
+ font.info.styleName = font.info.styleName + " BETA"
+
+ family = font.info.familyName # i.e. "Inter"
+ style = font.info.styleName # e.g. "Medium Italic"
+
+ # Update italicAngle
+ isitalic = style.find("Italic") != -1
+ if isitalic:
+ font.info.italicAngle = float('%.8g' % font.info.italicAngle)
+ else:
+ font.info.italicAngle = 0 # avoid "-0.0" value in UFO
+
+ # weight
+ font.info.openTypeOS2WeightClass = weight
+
+ # version (dummy)
+ updateFontVersion(font, dummy=True, isVF=False)
+
+ # Names
+ family_nosp = re.sub(r'\s', '', family)
+ style_nosp = re.sub(r'\s', '', style)
+ font.info.macintoshFONDName = "%s %s" % (family_nosp, style_nosp)
+ font.info.postscriptFontName = "%s-%s" % (family_nosp, style_nosp)
+
+ # name ID 16 "Typographic Family name"
+ font.info.openTypeNamePreferredFamilyName = family
+
+ # name ID 17 "Typographic Subfamily name"
+ font.info.openTypeNamePreferredSubfamilyName = style
+
+ # name ID 1 "Family name" (legacy, but required)
+ # Restriction:
+ # "shared among at most four fonts that differ only in weight or style"
+ # So we map as follows:
+ # - Regular => "Family", ("regular" | "italic" | "bold" | "bold italic")
+ # - Medium => "Family Medium", ("regular" | "italic")
+ # - Black => "Family Black", ("regular" | "italic")
+ # and so on.
+ subfamily = stripItalic(style).strip() # "A Italic" => "A", "A" => "A"
+ if len(subfamily) == 0:
+ subfamily = "Regular"
+ subfamily_lc = subfamily.lower()
+ if subfamily_lc == "regular" or subfamily_lc == "bold":
+ font.info.styleMapFamilyName = family
+ # name ID 2 "Subfamily name" (legacy, but required)
+ # Value must be one of: "regular", "italic", "bold", "bold italic"
+ if subfamily_lc == "regular":
+ if isitalic:
+ font.info.styleMapStyleName = "italic"
+ else:
+ font.info.styleMapStyleName = "regular"
+ else: # bold
+ if isitalic:
+ font.info.styleMapStyleName = "bold italic"
+ else:
+ font.info.styleMapStyleName = "bold"
+ else:
+ font.info.styleMapFamilyName = (family + ' ' + subfamily).strip()
+ # name ID 2 "Subfamily name" (legacy, but required)
+ if isitalic:
+ font.info.styleMapStyleName = "italic"
+ else:
+ font.info.styleMapStyleName = "regular"
+
+
+
+stripItalic_re = re.compile(r'(?:^|\b)italic\b|italic$', re.I | re.U)
+
+
+def stripItalic(name):
+ return stripItalic_re.sub('', name.strip())
diff --git a/misc/fontbuildlib/name.py b/misc/fontbuildlib/name.py
new file mode 100644
index 000000000..c999a243a
--- /dev/null
+++ b/misc/fontbuildlib/name.py
@@ -0,0 +1,143 @@
+from fontTools.ttLib import TTFont
+from .util import loadTTFont
+import os, sys, re
+
+# Adoptation of fonttools/blob/master/Snippets/rename-fonts.py
+
+WINDOWS_ENGLISH_IDS = 3, 1, 0x409
+MAC_ROMAN_IDS = 1, 0, 0
+
+LEGACY_FAMILY = 1
+TRUETYPE_UNIQUE_ID = 3
+FULL_NAME = 4
+POSTSCRIPT_NAME = 6
+PREFERRED_FAMILY = 16
+SUBFAMILY_NAME = 17
+WWS_FAMILY = 21
+
+
+FAMILY_RELATED_IDS = set([
+ LEGACY_FAMILY,
+ TRUETYPE_UNIQUE_ID,
+ FULL_NAME,
+ POSTSCRIPT_NAME,
+ PREFERRED_FAMILY,
+ WWS_FAMILY,
+])
+
+whitespace_re = re.compile(r'\s+')
+
+
+def removeWhitespace(s):
+ return whitespace_re.sub("", s)
+
+
+def setFullName(font, fullName):
+ nameTable = font["name"]
+ nameTable.setName(fullName, FULL_NAME, 1, 0, 0) # mac
+ nameTable.setName(fullName, FULL_NAME, 3, 1, 0x409) # windows
+
+
+def getFamilyName(font):
+ nameTable = font["name"]
+ r = None
+ for plat_id, enc_id, lang_id in (WINDOWS_ENGLISH_IDS, MAC_ROMAN_IDS):
+ for name_id in (PREFERRED_FAMILY, LEGACY_FAMILY):
+ r = nameTable.getName(nameID=name_id, platformID=plat_id, platEncID=enc_id, langID=lang_id)
+ if r is not None:
+ break
+ if r is not None:
+ break
+ if not r:
+ raise ValueError("family name not found")
+ return r.toUnicode()
+
+
+def removeWhitespaceFromStyles(font):
+ familyName = getFamilyName(font)
+
+ # collect subfamily (style) name IDs for variable font's named instances
+ vfInstanceSubfamilyNameIds = set()
+ if "fvar" in font:
+ for namedInstance in font["fvar"].instances:
+ vfInstanceSubfamilyNameIds.add(namedInstance.subfamilyNameID)
+
+ nameTable = font["name"]
+ for rec in nameTable.names:
+ rid = rec.nameID
+ if rid in (FULL_NAME, LEGACY_FAMILY):
+ # style part of family name
+ s = rec.toUnicode()
+ start = s.find(familyName)
+ if start != -1:
+ s = familyName + " " + removeWhitespace(s[start + len(familyName):])
+ else:
+ s = removeWhitespace(s)
+ rec.string = s
+ if rid in (SUBFAMILY_NAME,) or rid in vfInstanceSubfamilyNameIds:
+ rec.string = removeWhitespace(rec.toUnicode())
+ # else: ignore standard names unrelated to style
+
+
+def setFamilyName(font, nextFamilyName):
+ prevFamilyName = getFamilyName(font)
+ if prevFamilyName == nextFamilyName:
+ return
+ # raise Exception("identical family name")
+
+ def renameRecord(nameRecord, prevFamilyName, nextFamilyName):
+ # replaces prevFamilyName with nextFamilyName in nameRecord
+ s = nameRecord.toUnicode()
+ start = s.find(prevFamilyName)
+ if start != -1:
+ end = start + len(prevFamilyName)
+ nextFamilyName = s[:start] + nextFamilyName + s[end:]
+ nameRecord.string = nextFamilyName
+ return s, nextFamilyName
+
+ # postcript name can't contain spaces
+ psPrevFamilyName = prevFamilyName.replace(" ", "")
+ psNextFamilyName = nextFamilyName.replace(" ", "")
+ for rec in font["name"].names:
+ name_id = rec.nameID
+ if name_id not in FAMILY_RELATED_IDS:
+ # leave uninteresting records unmodified
+ continue
+ if name_id == POSTSCRIPT_NAME:
+ old, new = renameRecord(rec, psPrevFamilyName, psNextFamilyName)
+ elif name_id == TRUETYPE_UNIQUE_ID:
+ # The Truetype Unique ID rec may contain either the PostScript Name or the Full Name
+ if psPrevFamilyName in rec.toUnicode():
+ # Note: This is flawed -- a font called "Foo" renamed to "Bar Lol";
+ # if this record is not a PS record, it will incorrectly be rename "BarLol".
+ # However, in practice this is not abig deal since it's just an ID.
+ old, new = renameRecord(rec, psPrevFamilyName, psNextFamilyName)
+ else:
+ old, new = renameRecord(rec, prevFamilyName, nextFamilyName)
+ else:
+ old, new = renameRecord(rec, prevFamilyName, nextFamilyName)
+ # print(" %r: '%s' -> '%s'" % (rec, old, new))
+
+
+
+# def renameFontFamily(infile, outfile, newFamilyName):
+# font = loadTTFont(infile)
+# setFamilyName(font, newFamilyName)
+# # print('write "%s"' % outfile)
+# font.save(outfile)
+# font.close()
+
+
+# def main():
+# infile = "./build/fonts/var/Inter.var.ttf"
+# outfile = "./build/tmp/var2.otf"
+# renameFontFamily(infile, outfile, "Inter V")
+# print("%s familyName: %r" % (infile, getFamilyName(loadTTFont(infile)) ))
+# print("%s familyName: %r" % (outfile, getFamilyName(loadTTFont(outfile)) ))
+
+# if __name__ == "__main__":
+# sys.exit(main())
+
+# Similar to:
+# ttx -i -e -o ./build/tmp/var.ttx ./build/fonts/var/Inter.var.ttf
+# ttx -b --no-recalc-timestamp -o ./build/tmp/var.otf ./build/tmp/var.ttx
diff --git a/misc/fontbuildlib/util.py b/misc/fontbuildlib/util.py
new file mode 100644
index 000000000..9908d400d
--- /dev/null
+++ b/misc/fontbuildlib/util.py
@@ -0,0 +1,29 @@
+import sys
+import os
+import errno
+from fontTools.ttLib import TTFont
+from os.path import dirname, abspath, join as pjoin
+
+PYVER = sys.version_info[0]
+BASEDIR = abspath(pjoin(dirname(__file__), os.pardir, os.pardir))
+
+_enc_kwargs = {}
+if PYVER >= 3:
+ _enc_kwargs = {'encoding': 'utf-8'}
+
+
+def readTextFile(filename):
+ with open(filename, 'r', **_enc_kwargs) as f:
+ return f.read()
+
+
+def mkdirs(path):
+ try:
+ os.makedirs(path)
+ except OSError as e:
+ if e.errno != errno.EEXIST:
+ raise # raises the error again
+
+
+def loadTTFont(file):
+ return TTFont(file, recalcBBoxes=False, recalcTimestamp=False)
diff --git a/misc/fontbuildlib/version.py b/misc/fontbuildlib/version.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/misc/fontbuildlib/version.py
diff --git a/requirements.txt b/requirements.txt
index d38126b86..e7e186d36 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,16 +1,12 @@
-# note: These should match requirements of fontmake
-
-fontmake==2.0.1
-fonttools[lxml,unicode,ufo]==3.44.0
-glyphsLib==4.1.2
+fonttools[lxml,unicode,ufo]==4.0.1
+cu2qu==1.6.6
+glyphsLib==5.0.1
+ufo2ft[pathops]==2.9.1
+defcon[lxml]==0.6.0
skia-pathops==0.2.0.post2
-ufo2ft==2.9.1
-fs==2.4.10
-# for fontTools/varLib/interpolatable.py
-numpy==1.17.1
-scipy==1.3.1
-munkres==1.1.2
+# only used for DesignSpaceDocumentReader in fontbuild
+MutatorMath==2.1.2
# for woff2
brotli==1.0.7