New version of fontbuild which addresses several issues

Fixes for things that stopped working when we updated fontmake:
- restore glyph decomposition for VF
- restore glyph overlap removal for VF
- restore version metadata writing for VF

Improvements for VF
- fix "full name" name table entry to say "Inter" instead of "Inter Regular"

New and changed:
- "rename" command for renaming metadata like family and style, optionally
  saving a separate file. Used to produce new "Inter V" family.
- The "build" command no longer performs "style name compactation" for
  Google fonts. Instead, the new "rename" command is used.

Closes #198
Closes #202
This commit is contained in:
Rasmus Andersson 2019-10-22 12:27:06 -07:00
parent bc8b267b01
commit 0ba7c2b42f
2 changed files with 283 additions and 119 deletions

View file

@ -16,6 +16,8 @@ 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
@ -110,124 +112,96 @@ def findGlyphDirectives(g): # -> set<string> | None
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, familyName=None, compact_style_names=False, *args, **kwargs):
def __init__(self, compact_style_names=False, *args, **kwargs):
super(VarFontProject, self).__init__(*args, **kwargs)
self.familyName = familyName
self.compact_style_names = compact_style_names
def decompose_glyphs(self, designspace, glyph_filter=lambda g: True):
"""Move components of UFOs' glyphs to their outlines."""
for ufo in designspace:
log.info('Decomposing glyphs for ' + self._font_name(ufo))
for glyph in ufo:
if not glyph.components or not glyph_filter(glyph):
continue
self._deep_copy_contours(ufo, glyph, glyph, Transform())
glyph.clearComponents()
# 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
def _deep_copy_contours(self, ufo, parent, component, transformation):
"""Copy contours from component to parent, including nested components."""
for nested in component.components:
self._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 _build_interpolatable_masters(
self,
designspace,
ttf,
use_production_names=None,
reverse_direction=True,
conversion_error=None,
feature_writers=None,
cff_round_tolerance=None,
**kwargs,
):
# We decompose any glyph with reflected components to make sure
# that fontTools varLib is able to produce properly-slanting interpolation.
designspace = self._load_designspace_sources(designspace)
decomposeGlyphs = set()
removeOverlapsGlyphs = set()
masters = [s.font for s in designspace.sources]
# 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:
if self.familyName is not None:
ufo.info.familyName =\
ufo.info.familyName.replace('Inter', self.familyName)
ufo.info.styleMapFamilyName =\
ufo.info.styleMapFamilyName.replace('Inter', self.familyName)
ufo.info.postscriptFontName =\
ufo.info.postscriptFontName.replace('Inter', self.familyName.replace(' ', ''))
ufo.info.macintoshFONDName =\
ufo.info.macintoshFONDName.replace('Inter', self.familyName)
ufo.info.openTypeNamePreferredFamilyName =\
ufo.info.openTypeNamePreferredFamilyName.replace('Inter', self.familyName)
# patch style name if --compact-style-names is set
if args.compact_style_names:
if self.compact_style_names:
collapseFontStyleName(ufo)
# update font version
updateFontVersion(ufo, isVF=True)
updateFontVersion(ufo)
ufoname = basename(ufo.path)
# 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):
decomposeGlyphs.add(g.name)
glyphNamesToDecompose.add(g.name)
if 'removeoverlap' in directives:
if g.components and len(g.components) > 0:
decomposeGlyphs.add(g.name)
removeOverlapsGlyphs.add(g)
glyphNamesToDecompose.add(g.name)
glyphsToRemoveOverlaps.add(g)
self.decompose_glyphs(masters, lambda g: g.name in decomposeGlyphs)
# decompose
if log.isEnabledFor(logging.INFO):
log.info('Decomposing glyphs:\n %s', "\n ".join(glyphNamesToDecompose))
decompose_glyphs(masters, glyphNamesToDecompose)
if len(removeOverlapsGlyphs) > 0:
# remove overlaps
if len(glyphsToRemoveOverlaps) > 0:
rmoverlapFilter = RemoveOverlapsFilter(backend='pathops')
rmoverlapFilter.start()
for g in removeOverlapsGlyphs:
if log.isEnabledFor(logging.INFO):
log.info(
'Removing overlaps in glyph "%s" of %s',
g.name,
basename(g.getParent().path)
'Removing overlaps in glyphs:\n %s',
"\n ".join(set([g.name for g in glyphsToRemoveOverlaps])),
)
for g in glyphsToRemoveOverlaps:
rmoverlapFilter.filter(g)
if ttf:
return ufo2ft.compileInterpolatableTTFsFromDS(
designspace,
useProductionNames=use_production_names,
reverseDirection=reverse_direction,
cubicConversionError=conversion_error,
featureWriters=feature_writers,
inplace=True,
)
else:
return ufo2ft.compileInterpolatableOTFsFromDS(
designspace,
useProductionNames=use_production_names,
roundTolerance=cff_round_tolerance,
featureWriters=feature_writers,
inplace=True,
)
# handle control back to fontmake
return designspace
def updateFontVersion(font, dummy=False):
def updateFontVersion(font, dummy=False, isVF=False):
version = getVersion()
buildtag = getGitHash()
now = datetime.datetime.utcnow()
@ -242,11 +216,13 @@ def updateFontVersion(font, dummy=False):
font.info.woffMajorVersion = versionMajor
font.info.woffMinorVersion = versionMinor
font.info.year = now.year
font.info.openTypeNameVersion = "Version %d.%03d;git-%s" % (
versionMajor, versionMinor, buildtag)
font.info.openTypeNameUniqueID = "%s %s:%d:%s" % (
font.info.familyName, font.info.styleName, now.year, buildtag)
# creation date & time (YYYY/MM/DD HH:MM:SS)
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")
@ -362,6 +338,8 @@ class Main(object):
compile-var Build variable font files
glyphsync Generate designspace and UFOs from Glyphs file
instancegen Generate instance UFOs for designspace
checkfont Verify integrity of font files
rename Rename fonts
'''.strip().replace('\n ', '\n'))
argparser.add_argument('-v', '--verbose', action='store_true',
@ -426,9 +404,6 @@ class Main(object):
argparser.add_argument('-o', '--output', metavar='<fontfile>',
help='Output font file')
argparser.add_argument('--name', metavar='<family-name>',
help='Override family name, replacing "Inter"')
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\"")
@ -437,27 +412,14 @@ class Main(object):
# decide output filename (or check user-provided name)
outfilename = args.output
outformat = 'variable' # TTF
if outfilename is None or outfilename == '':
outfilename = os.path.splitext(basename(args.srcfile))[0] + '.var.ttf'
outfilename = os.path.splitext(basename(args.srcfile))[0] + '.var.otf'
log.info('setting --output %r' % outfilename)
else:
outfileext = os.path.splitext(outfilename)[1]
if outfileext.lower() == '.otf':
outformat = 'variable-cff2'
elif outfileext.lower() != '.ttf':
fatal('Invalid file extension %r (expected ".ttf")' % outfileext)
mkdirs(dirname(outfilename))
# override family name?
familyName = None
if args.name is not None and len(args.name) > 0:
familyName = args.name
project = VarFontProject(
verbose=self.logLevelName,
familyName=familyName,
compact_style_names=args.compact_style_names,
)
project.run_from_designspace(
@ -467,11 +429,22 @@ class Main(object):
use_production_names=True,
round_instances=True,
output_path=outfilename,
output=[outformat],
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)
# Note: we can't run ots-sanitize on the generated file as OTS
@ -652,7 +625,7 @@ class Main(object):
italic = False
if tag == 'italic':
italic = True
elif tag != 'upright':
elif tag != 'roman':
raise Exception('unexpected tag ' + tag)
for a in ds.axes:
@ -785,11 +758,11 @@ class Main(object):
self.log("write %s" % relpath(designspace_file, os.getcwd()))
designspace.write(designspace_file)
# upright designspace
upright_designspace_file = pjoin(outdir, 'Inter-upright.designspace')
# roman designspace
roman_designspace_file = pjoin(outdir, 'Inter-roman.designspace')
p = Process(
target=self._genSubsetDesignSpace,
args=(designspace, 'upright', upright_designspace_file)
args=(designspace, 'roman', roman_designspace_file)
)
p.start()
procs.append(p)
@ -929,5 +902,48 @@ class Main(object):
sys.exit(1)
def cmd_rename(self, argv):
argparser = argparse.ArgumentParser(
usage='%(prog)s rename <options> <file>',
description='Rename family and/or styles of font'
)
a = lambda *args, **kwargs: argparser.add_argument(*args, **kwargs)
a('-o', '--output', metavar='<file>',
help='Output font file. Defaults to input file (overwrite.)')
a('--family', metavar='<name>',
help='Rename family to <name>')
a('--compact-style', action='store_true',
help='Rename style names to CamelCase. e.g. "Extra Bold Italic" -> "ExtraBoldItalic"')
a('input', metavar='<file>',
help='Input font file')
args = argparser.parse_args(argv)
infile = args.input
outfile = args.output or infile
font = font_names.loadFont(infile)
editCount = 0
try:
if args.family:
editCount += 1
font_names.setFamilyName(font, args.family)
if args.compact_style:
editCount += 1
font_names.removeWhitespaceFromStyles(font)
if editCount > 0:
font.save(outfile)
else:
print("no rename options provided", file=sys.stderr)
argparser.print_usage(sys.stderr)
sys.exit(1)
finally:
font.close()
if __name__ == '__main__':
Main().main(sys.argv)

148
misc/tools/font_names.py Normal file
View file

@ -0,0 +1,148 @@
#!/usr/bin/env python
from fontTools.ttLib import TTFont
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 loadFont(file):
return TTFont(file, recalcBBoxes=False, recalcTimestamp=False)
def renameFontFamily(infile, outfile, newFamilyName):
font = loadFont(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(loadFont(infile)) ))
print("%s familyName: %r" % (outfile, getFamilyName(loadFont(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