fontbuild: remove use of fontmake, simplifying things.

This commit is contained in:
Rasmus Andersson 2019-10-22 17:00:58 -07:00
parent 9c444deded
commit aa7ad2d7a0
9 changed files with 612 additions and 389 deletions

View file

@ -1,43 +1,30 @@
#!/usr/bin/env python #!/usr/bin/env python
from __future__ import print_function, absolute_import from __future__ import print_function, absolute_import
import sys
import sys, os import os
from os.path import dirname, basename, abspath, relpath, join as pjoin from os.path import dirname, basename, abspath, relpath, join as pjoin
sys.path.append(abspath(pjoin(dirname(__file__), 'tools'))) 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 from collections import OrderedDict
import argparse import argparse
import datetime
import errno
import glyphsLib import glyphsLib
import logging import logging
import re import re
import signal import signal
import subprocess import subprocess
import ufo2ft
import font_names
from functools import partial from fontTools.designspaceLib import DesignSpaceDocument
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 mutatorMath.ufo.document import DesignSpaceDocumentReader from mutatorMath.ufo.document import DesignSpaceDocumentReader
from multiprocessing import Process, Queue from multiprocessing import Process, Queue
from ufo2ft.filters.removeOverlaps import RemoveOverlapsFilter from glyphsLib.interpolation import apply_instance_data
from ufo2ft import CFFOptimization
from fontbuildlib import FontBuilder
from fontbuildlib.util import mkdirs, loadTTFont
from fontbuildlib.info import setFontInfo, updateFontVersion
from fontbuildlib.name import setFamilyName, removeWhitespaceFromStyles
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
stripItalic_re = re.compile(r'(?:^|\b)italic\b|italic$', re.I | re.U)
def stripItalic(name):
return stripItalic_re.sub('', name.strip())
def sighandler(signum, frame): def sighandler(signum, frame):
@ -46,263 +33,11 @@ def sighandler(signum, frame):
sys.exit(1) 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): def fatal(msg):
print(sys.argv[0] + ': ' + msg, file=sys.stderr) print(sys.argv[0] + ': ' + msg, file=sys.stderr)
sys.exit(1) 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): def collapseFontStyleName(font):
# collapse whitespace in style name. i.e. "Semi Bold Italic" -> "SemiBoldItalic" # collapse whitespace in style name. i.e. "Semi Bold Italic" -> "SemiBoldItalic"
font.info.styleName = re.sub(r'\s', '', font.info.styleName) font.info.styleName = re.sub(r'\s', '', font.info.styleName)
@ -365,7 +100,8 @@ class Main(object):
# parse CLI arguments # parse CLI arguments
args = argparser.parse_args(argv[1:i]) args = argparser.parse_args(argv[1:i])
logFormat = '%(funcName)s: %(message)s'
# log config
if args.quiet: if args.quiet:
self.quiet = True self.quiet = True
if args.debug: if args.debug:
@ -373,14 +109,13 @@ class Main(object):
if args.verbose: if args.verbose:
fatal("--quiet and --verbose are mutually exclusive arguments") fatal("--quiet and --verbose are mutually exclusive arguments")
elif args.debug: elif args.debug:
logging.basicConfig(level=logging.DEBUG, format=logFormat) logging.basicConfig(level=logging.DEBUG, format='%(funcName)s: %(message)s')
self.logLevelName = 'DEBUG' self.logLevelName = 'DEBUG'
elif args.verbose: elif args.verbose:
logging.basicConfig(level=logging.INFO, format=logFormat) logging.basicConfig(level=logging.INFO, format='%(funcName)s: %(message)s')
self.logLevelName = 'INFO' self.logLevelName = 'INFO'
else: else:
logFormat = '%(message)s' logging.basicConfig(level=logging.WARNING, format='%(message)s')
logging.basicConfig(level=logging.WARNING, format=logFormat)
self.logLevelName = 'WARNING' self.logLevelName = 'WARNING'
if args.chdir: if args.chdir:
@ -404,49 +139,22 @@ class Main(object):
argparser.add_argument('-o', '--output', metavar='<fontfile>', argparser.add_argument('-o', '--output', metavar='<fontfile>',
help='Output font file') 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) args = argparser.parse_args(argv)
# decide output filename (or check user-provided name) # decide output filename (or check user-provided name)
outfilename = args.output outfilename = args.output
if outfilename is None or outfilename == '': if outfilename is None or outfilename == '':
outfilename = os.path.splitext(basename(args.srcfile))[0] + '.var.otf' outfilename = pjoin(
log.info('setting --output %r' % outfilename) dirname(args.srcfile),
os.path.splitext(basename(args.srcfile))[0] + '.otf'
)
log.debug('setting --output %r' % outfilename)
mkdirs(dirname(outfilename)) mkdirs(dirname(outfilename))
project = VarFontProject( FontBuilder().buildVariable(args.srcfile, outfilename)
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()
self.log("write %s" % outfilename) self.log("write %s" % outfilename)
# Note: we can't run ots-sanitize on the generated file as OTS # Note: we can't run ots-sanitize on the generated file as OTS
# currently doesn't support variable fonts. # currently doesn't support variable fonts.
@ -461,80 +169,40 @@ class Main(object):
help='Source file (.ufo file)') help='Source file (.ufo file)')
argparser.add_argument('-o', '--output', metavar='<fontfile>', 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', argparser.add_argument('--validate', action='store_true',
help='Enable ufoLib validation on reading/writing UFO files') 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) args = argparser.parse_args(argv)
ext_to_format = { # write an OTF/CFF (or a TTF if false)
'.ufo': 'ufo', cff = True
'.otf': 'otf',
'.ttf': 'ttf',
# non-filename mapping targets: (kept for completeness)
# 'ttf-interpolatable',
# 'variable',
}
# decide output filename # decide output filename
outfilename = args.output outfilename = args.output
if outfilename is None or outfilename == '': if outfilename is None or outfilename == '':
outfilename = os.path.splitext(basename(args.srcfile))[0] + '.otf' outfilename = pjoin(
log.info('setting --output %r' % outfilename) dirname(args.srcfile),
os.path.splitext(basename(args.srcfile))[0] + '.otf'
# build formats list from filename extension )
formats = [] log.debug('setting --output %r' % outfilename)
# 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])
else: 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 # temp file to write to
tmpfilename = pjoin(self.tmpdir, basename(outfilename)) tmpfilename = pjoin(self.tmpdir, basename(outfilename))
mkdirs(self.tmpdir) mkdirs(self.tmpdir)
project = FontProject(verbose=self.logLevelName, validate_ufo=args.validate) # build OTF or TTF file from UFO
FontBuilder().buildStatic(args.srcfile, tmpfilename, cff)
ufo = Font(args.srcfile) # Run ots-sanitize on produced OTF/TTF file and write sanitized version to outfilename
self._ots_sanitize(tmpfilename, outfilename)
# 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,
)
if output_path == tmpfilename:
# Run ots-sanitize on produced OTF/TTF file and write sanitized version
# to outfilename
self._ots_sanitize(output_path, outfilename)
def _ots_sanitize(self, tmpfilename, outfilename): def _ots_sanitize(self, tmpfilename, outfilename):
@ -680,6 +348,8 @@ class Main(object):
if outdir is None: if outdir is None:
outdir = dirname(args.glyphsfile) outdir = dirname(args.glyphsfile)
# TODO: Move into fontbuildlib
# files # files
master_dir = outdir master_dir = outdir
glyphsfile = args.glyphsfile glyphsfile = args.glyphsfile
@ -808,7 +478,7 @@ class Main(object):
verbose=True verbose=True
) )
designspace = designspaceLib.DesignSpaceDocument() designspace = DesignSpaceDocument()
designspace.read(designspace_file) designspace.read(designspace_file)
# Generate UFOs for instances # Generate UFOs for instances
@ -923,23 +593,24 @@ class Main(object):
infile = args.input infile = args.input
outfile = args.output or infile outfile = args.output or infile
font = font_names.loadFont(infile) font = loadTTFont(infile)
editCount = 0 editCount = 0
try: try:
if args.family: if args.family:
editCount += 1 editCount += 1
font_names.setFamilyName(font, args.family) setFamilyName(font, args.family)
if args.compact_style: if args.compact_style:
editCount += 1 editCount += 1
font_names.removeWhitespaceFromStyles(font) removeWhitespaceFromStyles(font)
if editCount > 0: if editCount == 0:
font.save(outfile)
else:
print("no rename options provided", file=sys.stderr) print("no rename options provided", file=sys.stderr)
argparser.print_usage(sys.stderr) argparser.print_help(sys.stderr)
sys.exit(1) sys.exit(1)
return
font.save(outfile)
finally: finally:
font.close() font.close()

View file

@ -0,0 +1 @@
from .builder import FontBuilder

View file

@ -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

View file

@ -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)

148
misc/fontbuildlib/info.py Normal file
View file

@ -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())

143
misc/fontbuildlib/name.py Normal file
View file

@ -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

29
misc/fontbuildlib/util.py Normal file
View file

@ -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)

View file

View file

@ -1,16 +1,12 @@
# note: These should match requirements of fontmake fonttools[lxml,unicode,ufo]==4.0.1
cu2qu==1.6.6
fontmake==2.0.1 glyphsLib==5.0.1
fonttools[lxml,unicode,ufo]==3.44.0 ufo2ft[pathops]==2.9.1
glyphsLib==4.1.2 defcon[lxml]==0.6.0
skia-pathops==0.2.0.post2 skia-pathops==0.2.0.post2
ufo2ft==2.9.1
fs==2.4.10
# for fontTools/varLib/interpolatable.py # only used for DesignSpaceDocumentReader in fontbuild
numpy==1.17.1 MutatorMath==2.1.2
scipy==1.3.1
munkres==1.1.2
# for woff2 # for woff2
brotli==1.0.7 brotli==1.0.7