mirror of
https://github.com/rsms/inter.git
synced 2024-11-23 11:43:47 +03:00
641 lines
19 KiB
Python
Executable File
641 lines
19 KiB
Python
Executable File
#!/usr/bin/env python
|
|
from __future__ import print_function, absolute_import
|
|
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, execproc
|
|
from collections import OrderedDict
|
|
|
|
import argparse
|
|
import glyphsLib
|
|
import logging
|
|
import re
|
|
import signal
|
|
import subprocess
|
|
|
|
from fontTools.designspaceLib import DesignSpaceDocument
|
|
from mutatorMath.ufo.document import DesignSpaceDocumentReader
|
|
from multiprocessing import Process, Queue
|
|
from glyphsLib.interpolation import apply_instance_data
|
|
|
|
from fontbuildlib import FontBuilder
|
|
from fontbuildlib.util import mkdirs, loadTTFont
|
|
from fontbuildlib.info import setFontInfo
|
|
from fontbuildlib.name import setFamilyName, renameStylesGoogleFonts
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
# set to true to exclude anchors from .glyphs when generating UFOs
|
|
EXCLUDE_ANCHORS = False
|
|
|
|
|
|
def sighandler(signum, frame):
|
|
sys.stdout.write('\n')
|
|
sys.stdout.flush()
|
|
sys.exit(1)
|
|
|
|
|
|
def fatal(msg):
|
|
print(sys.argv[0] + ': ' + msg, file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
|
|
def collapseFontStyleName(font):
|
|
# collapse whitespace in style name. i.e. "Semi Bold Italic" -> "SemiBoldItalic"
|
|
font.info.styleName = re.sub(r'\s', '', font.info.styleName)
|
|
# update info to have style changes "trickle down" to other metadata
|
|
setFontInfo(font, font.info.openTypeOS2WeightClass)
|
|
|
|
|
|
class Main(object):
|
|
|
|
def __init__(self):
|
|
self.tmpdir = pjoin(BASEDIR,'build','tmp')
|
|
self.quiet = False
|
|
self.logLevelName = 'WARNING'
|
|
|
|
|
|
def log(self, msg):
|
|
if not self.quiet:
|
|
print(msg)
|
|
|
|
|
|
def main(self, argv):
|
|
# make ^C instantly exit program
|
|
signal.signal(signal.SIGINT, sighandler)
|
|
|
|
argparser = argparse.ArgumentParser(
|
|
description='',
|
|
usage='''
|
|
%(prog)s [options] <command> [<args>]
|
|
|
|
Commands:
|
|
compile Build font files
|
|
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',
|
|
help='Print more details')
|
|
|
|
argparser.add_argument('--debug', action='store_true',
|
|
help='Print lots of details')
|
|
|
|
argparser.add_argument('-q', '--quiet', action='store_true',
|
|
help='Only print errors')
|
|
|
|
argparser.add_argument('-C', metavar='<dir>', dest='chdir',
|
|
help='Run as if %(prog)s started in <dir> instead of the '+\
|
|
'current working directory.')
|
|
|
|
argparser.add_argument('command', metavar='<command>')
|
|
|
|
# search past base arguments
|
|
i = 1
|
|
while i < len(argv) and argv[i][0] == '-':
|
|
i = i + 1
|
|
i = i + 1
|
|
|
|
# parse CLI arguments
|
|
args = argparser.parse_args(argv[1:i])
|
|
|
|
# log config
|
|
if args.quiet:
|
|
self.quiet = True
|
|
if args.debug:
|
|
fatal("--quiet and --debug are mutually exclusive arguments")
|
|
if args.verbose:
|
|
fatal("--quiet and --verbose are mutually exclusive arguments")
|
|
elif args.debug:
|
|
logging.basicConfig(level=logging.DEBUG, format='%(funcName)s: %(message)s')
|
|
self.logLevelName = 'DEBUG'
|
|
elif args.verbose:
|
|
logging.basicConfig(level=logging.INFO, format='%(funcName)s: %(message)s')
|
|
self.logLevelName = 'INFO'
|
|
else:
|
|
logging.basicConfig(level=logging.WARNING, format='%(message)s')
|
|
self.logLevelName = 'WARNING'
|
|
|
|
if args.chdir:
|
|
os.chdir(args.chdir)
|
|
|
|
cmd = 'cmd_' + args.command.replace('-', '_')
|
|
if not hasattr(self, cmd):
|
|
fatal('Unrecognized command %s. Try --help' % args.command)
|
|
getattr(self, cmd)(argv[i:])
|
|
|
|
|
|
|
|
def cmd_compile_var(self, argv):
|
|
argparser = argparse.ArgumentParser(
|
|
usage='%(prog)s compile-var [-h] [-o <file>] <designspace>',
|
|
description='Compile variable font file')
|
|
|
|
argparser.add_argument('srcfile', metavar='<designspace>',
|
|
help='Source file (.designspace file)')
|
|
|
|
argparser.add_argument('-o', '--output', metavar='<fontfile>',
|
|
help='Output font file')
|
|
|
|
args = argparser.parse_args(argv)
|
|
|
|
# decide output filename (or check user-provided name)
|
|
outfilename = args.output
|
|
if outfilename is None or outfilename == '':
|
|
outfilename = pjoin(
|
|
dirname(args.srcfile),
|
|
os.path.splitext(basename(args.srcfile))[0] + '.otf'
|
|
)
|
|
log.debug('setting --output %r' % outfilename)
|
|
|
|
mkdirs(dirname(outfilename))
|
|
|
|
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.
|
|
|
|
|
|
|
|
def cmd_compile(self, argv):
|
|
argparser = argparse.ArgumentParser(
|
|
usage='%(prog)s compile [-h] [-o <file>] <ufo>',
|
|
description='Compile font files')
|
|
|
|
argparser.add_argument('srcfile', metavar='<ufo>',
|
|
help='Source file (.ufo file)')
|
|
|
|
argparser.add_argument('-o', '--output', metavar='<fontfile>',
|
|
help='Output font file (.otf or .ttf)')
|
|
|
|
argparser.add_argument('--validate', action='store_true',
|
|
help='Enable ufoLib validation on reading/writing UFO files')
|
|
|
|
args = argparser.parse_args(argv)
|
|
|
|
# write an OTF/CFF (or a TTF if false)
|
|
cff = True
|
|
|
|
# decide output filename
|
|
outfilename = args.output
|
|
if outfilename is None or outfilename == '':
|
|
outfilename = pjoin(
|
|
dirname(args.srcfile),
|
|
os.path.splitext(basename(args.srcfile))[0] + '.otf'
|
|
)
|
|
log.debug('setting --output %r' % outfilename)
|
|
else:
|
|
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)
|
|
|
|
# build OTF or TTF file from UFO
|
|
FontBuilder().buildStatic(args.srcfile, outfilename, cff)
|
|
|
|
# code to pipe font through ots-sanitize:
|
|
# # temp file to write to
|
|
# tmpfilename = pjoin(self.tmpdir, basename(outfilename))
|
|
# mkdirs(self.tmpdir)
|
|
#
|
|
# # build OTF or TTF file from UFO
|
|
# FontBuilder().buildStatic(args.srcfile, tmpfilename, cff)
|
|
#
|
|
# # 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):
|
|
# # run through ots-sanitize
|
|
# # for filename in args.output:
|
|
# tmpfile = pjoin(self.tmpdir, tmpfilename)
|
|
# mkdirs(dirname(outfilename))
|
|
# success = True
|
|
# try:
|
|
# self.log("write %s" % outfilename)
|
|
# otssan_res = subprocess.check_output(
|
|
# ['ots-sanitize', tmpfile, outfilename],
|
|
# # ['cp', tmpfile, outfilename],
|
|
# shell=False
|
|
# ).strip()
|
|
# # Note: ots-sanitize does not exit with an error in many cases where
|
|
# # it fails to sanitize the font.
|
|
# success = str(otssan_res).find('Failed') == -1
|
|
# except:
|
|
# success = False
|
|
# if len(otssan_res) == 0:
|
|
# otssan_res = 'error'
|
|
#
|
|
# if success:
|
|
# os.unlink(tmpfile)
|
|
# else:
|
|
# fatal('ots-sanitize failed for %s: %s' % (tmpfile, otssan_res))
|
|
|
|
|
|
|
|
def _glyphsyncWriteUFO(self, font, weight, ufo_path):
|
|
# fixup font info
|
|
setFontInfo(font, weight)
|
|
|
|
# cleanup lib
|
|
lib = dict()
|
|
for key, value in font.lib.items():
|
|
if key.startswith('com.schriftgestaltung'):
|
|
continue
|
|
if key == 'public.postscriptNames':
|
|
continue
|
|
lib[key] = value
|
|
font.lib.clear()
|
|
font.lib.update(lib)
|
|
|
|
# remove all but the primary (default) layer
|
|
layers = font.layers
|
|
defaultLayer = layers.defaultLayer
|
|
delLayerNames = set()
|
|
for layer in layers:
|
|
if layer != defaultLayer:
|
|
delLayerNames.add(layer.name)
|
|
for layerName in delLayerNames:
|
|
del(layers[layerName])
|
|
|
|
# process glyphs
|
|
glyphOrder = OrderedDict([(k,None) for k in font.lib['public.glyphOrder']])
|
|
for g in font:
|
|
if not g.lib.get('com.schriftgestaltung.Glyphs.Export', True):
|
|
if g.name in glyphOrder:
|
|
del(glyphOrder[g.name])
|
|
g.unicodes = []
|
|
if EXCLUDE_ANCHORS:
|
|
g.clearAnchors()
|
|
if 'com.schriftgestaltung.Glyphs.lastChange' in g.lib:
|
|
del(g.lib['com.schriftgestaltung.Glyphs.lastChange'])
|
|
|
|
# update possibly modified glyphorder
|
|
font.lib['public.glyphOrder'] = list(glyphOrder)
|
|
|
|
# write UFO file
|
|
self.log("write %s" % relpath(ufo_path, os.getcwd()))
|
|
font.save(ufo_path, overwrite=True, validate=False)
|
|
|
|
|
|
def _genSubsetDesignSpace(self, designspace, tag, filename):
|
|
ds = designspace
|
|
italic = False
|
|
if tag == 'italic':
|
|
italic = True
|
|
elif tag != 'roman':
|
|
raise Exception('unexpected tag ' + tag)
|
|
|
|
for a in ds.axes:
|
|
if a.tag == "slnt":
|
|
ds.axes.remove(a)
|
|
break
|
|
|
|
rmlist = []
|
|
hasDefault = not italic
|
|
for source in designspace.sources:
|
|
isitalic = source.name.find('italic') != -1
|
|
if italic != isitalic:
|
|
rmlist.append(source)
|
|
elif italic and not hasDefault:
|
|
source.copyLib = True
|
|
source.copyInfo = True
|
|
source.copyGroups = True
|
|
source.copyFeatures = True
|
|
hasDefault = True
|
|
for source in rmlist:
|
|
designspace.sources.remove(source)
|
|
|
|
rmlist = []
|
|
for instance in designspace.instances:
|
|
isitalic = instance.name.find('italic') != -1
|
|
if italic != isitalic:
|
|
rmlist.append(instance)
|
|
for instance in rmlist:
|
|
designspace.instances.remove(instance)
|
|
|
|
self.log("write %s" % relpath(filename, os.getcwd()))
|
|
ds.write(filename)
|
|
|
|
|
|
|
|
def cmd_glyphsync(self, argv):
|
|
argparser = argparse.ArgumentParser(
|
|
usage='%(prog)s glyphsync <glyphsfile> [options]',
|
|
description='Generates designspace and UFOs from Glyphs file')
|
|
|
|
argparser.add_argument('glyphsfile', metavar='<glyphsfile>',
|
|
help='Glyphs source file')
|
|
|
|
argparser.add_argument('-o', '--outdir', metavar='<dir>',
|
|
help='''Write output to <dir>. If omitted, designspace and UFOs are
|
|
written to the directory of the glyphs file.
|
|
'''.strip().replace('\n ', ''))
|
|
|
|
args = argparser.parse_args(argv)
|
|
|
|
outdir = args.outdir
|
|
if outdir is None:
|
|
outdir = dirname(args.glyphsfile)
|
|
|
|
# TODO: Move into fontbuildlib
|
|
|
|
# files
|
|
master_dir = outdir
|
|
glyphsfile = args.glyphsfile
|
|
name = os.path.splitext(basename(glyphsfile))[0] # e.g. "Inter"
|
|
designspace_file = pjoin(outdir, name + '.designspace')
|
|
instance_dir = pjoin(BASEDIR, 'build', 'ufo')
|
|
|
|
# load glyphs project file
|
|
self.log("generating %s from %s" % (
|
|
relpath(designspace_file, os.getcwd()),
|
|
relpath(glyphsfile, os.getcwd())
|
|
))
|
|
font = glyphsLib.GSFont(glyphsfile)
|
|
|
|
# remove archive layers and backgrounds
|
|
masterLayerIDs = set()
|
|
for master in font.masters:
|
|
masterLayerIDs.add(master.id)
|
|
for glyph in font.glyphs:
|
|
for layer in list(glyph.layers): # list to accumulate iterator since we del()
|
|
# remove background images from all layers
|
|
layer.backgroundImage = None
|
|
lname = layer.name
|
|
lid = layer.layerId
|
|
if lname[0] != '_' and (lid in masterLayerIDs or lname.find('{') != -1):
|
|
# Keep only layers which are masters or bracket layers.
|
|
# Next, clear background to speed up UFO generation.
|
|
layer.background.paths = []
|
|
layer.background.components = []
|
|
layer.background.anchors = []
|
|
layer.background.hints = []
|
|
layer.background.guides = []
|
|
else:
|
|
del(glyph.layers[lid])
|
|
|
|
# generate designspace from glyphs project
|
|
designspace = glyphsLib.to_designspace(
|
|
font,
|
|
propagate_anchors=(not EXCLUDE_ANCHORS),
|
|
instance_dir=relpath(instance_dir, master_dir),
|
|
store_editor_state=False, # do not store glyphs editor UI state in UFOs
|
|
)
|
|
|
|
# strip lib data
|
|
designspace.lib.clear()
|
|
|
|
# patch and write UFO files
|
|
# TODO: Only write out-of-date UFOs
|
|
procs = []
|
|
for source in designspace.sources:
|
|
# source : fontTools.designspaceLib.SourceDescriptor
|
|
# source.font : ufoLib2.objects.font.Font
|
|
ufo_path = pjoin(master_dir, source.filename)
|
|
# no need to also set the relative 'filename' attribute as that
|
|
# will be auto-updated on writing the designspace document
|
|
if source.styleName == "Italic Italic":
|
|
# Workaround for Glyphs limitation
|
|
# (Base italic master can't be called just Italic, so it's called
|
|
# "Italic Italic" which is converted here to just "Italic")
|
|
ufo_path = pjoin(master_dir, name + '-Italic.ufo')
|
|
source.styleName = "Italic"
|
|
source.name = "italic"
|
|
source.font.info.styleName = source.styleName
|
|
elif source.styleName == "Black Italic Italic":
|
|
ufo_path = pjoin(master_dir, name + '-BlackItalic.ufo')
|
|
source.styleName = "Black Italic"
|
|
source.name = "blackitalic"
|
|
source.font.info.styleName = source.styleName
|
|
elif source.styleName == "Thin Italic Italic":
|
|
ufo_path = pjoin(master_dir, name + '-ThinItalic.ufo')
|
|
source.styleName = "Thin Italic"
|
|
source.name = "thinitalic"
|
|
source.font.info.styleName = source.styleName
|
|
else:
|
|
# name "Black" => "black"
|
|
source.name = source.styleName.lower().replace(' ', '')
|
|
|
|
source.path = ufo_path
|
|
weight = int(source.location['Weight'])
|
|
|
|
# serial
|
|
# self._glyphsyncWriteUFO(source.font, weight, ufo_path)
|
|
|
|
# parallel
|
|
p = Process(
|
|
target=self._glyphsyncWriteUFO,
|
|
args=(source.font, weight, ufo_path)
|
|
)
|
|
p.start()
|
|
procs.append(p)
|
|
|
|
# patch instance names
|
|
for instance in designspace.instances:
|
|
# name "Black Italic" => "blackitalic"
|
|
instance.name = instance.styleName.lower().replace(' ', '')
|
|
|
|
self.log("write %s" % relpath(designspace_file, os.getcwd()))
|
|
designspace.write(designspace_file)
|
|
|
|
# roman designspace
|
|
roman_designspace_file = pjoin(outdir, name + '-roman.designspace')
|
|
p = Process(
|
|
target=self._genSubsetDesignSpace,
|
|
args=(designspace, 'roman', roman_designspace_file)
|
|
)
|
|
p.start()
|
|
procs.append(p)
|
|
|
|
# italic designspace
|
|
italic_designspace_file = pjoin(outdir, name + '-italic.designspace')
|
|
p = Process(
|
|
target=self._genSubsetDesignSpace,
|
|
args=(designspace, 'italic', italic_designspace_file)
|
|
)
|
|
p.start()
|
|
procs.append(p)
|
|
|
|
for p in procs:
|
|
p.join()
|
|
|
|
|
|
|
|
def cmd_instancegen(self, argv):
|
|
argparser = argparse.ArgumentParser(
|
|
description='Generate UFO instances from designspace')
|
|
|
|
argparser.add_argument('designspacefile', metavar='<designspace>',
|
|
help='Designspace file')
|
|
|
|
argparser.add_argument('instances', metavar='<instance-name>', nargs='*',
|
|
help='Style instances to generate. Omit to generate all.')
|
|
|
|
args = argparser.parse_args(argv)
|
|
|
|
instances = set([s.lower() for s in args.instances])
|
|
all_instances = len(instances) == 0
|
|
|
|
# files
|
|
designspace_file = args.designspacefile
|
|
instance_dir = pjoin(BASEDIR, 'build', 'ufo')
|
|
|
|
# DesignSpaceDocumentReader generates UFOs
|
|
gen = DesignSpaceDocumentReader(
|
|
designspace_file,
|
|
ufoVersion=3,
|
|
roundGeometry=True,
|
|
verbose=True
|
|
)
|
|
|
|
designspace = DesignSpaceDocument()
|
|
designspace.read(designspace_file)
|
|
|
|
# Generate UFOs for instances
|
|
instance_weight = dict()
|
|
instance_files = set()
|
|
for instance in designspace.instances:
|
|
if all_instances or instance.name in instances:
|
|
filebase = basename(instance.filename)
|
|
relname = relpath(
|
|
pjoin(dirname(designspace_file), instance.filename),
|
|
os.getcwd()
|
|
)
|
|
self.log('generating %s' % relname)
|
|
gen.readInstance(("name", instance.name))
|
|
instance_files.add(instance.filename)
|
|
instance_weight[filebase] = int(instance.location['Weight'])
|
|
if not all_instances:
|
|
instances.remove(instance.name)
|
|
|
|
if len(instances) > 0:
|
|
fatal('unknown style(s): %s' % ', '.join(list(instances)))
|
|
|
|
ufos = apply_instance_data(designspace_file, instance_files)
|
|
|
|
# patch ufos (list of defcon.Font instances)
|
|
italicAngleKey = 'com.schriftgestaltung.customParameter.' +\
|
|
'InstanceDescriptorAsGSInstance.italicAngle'
|
|
for font in ufos:
|
|
# move italicAngle from lib to info
|
|
italicAngle = font.lib.get(italicAngleKey)
|
|
if italicAngle != None:
|
|
italicAngle = float(italicAngle)
|
|
del(font.lib[italicAngleKey])
|
|
font.info.italicAngle = italicAngle
|
|
|
|
# clear anchors
|
|
if EXCLUDE_ANCHORS:
|
|
for g in font:
|
|
g.clearAnchors()
|
|
|
|
# update font info
|
|
weight = instance_weight[basename(font.path)]
|
|
setFontInfo(font, weight)
|
|
|
|
font.save()
|
|
|
|
|
|
def checkfont(self, fontfile, q):
|
|
res, ok = execproc('ots-idempotent', fontfile)
|
|
# Note: ots-idempotent does not exit with an error in many cases where
|
|
# it fails to sanitize the font, so we look at its output to determine
|
|
# success or failure.
|
|
if not ok or res.find('Failed') != -1:
|
|
log.error('%s: checkfont ots-idempotent: %s' % (fontfile, res))
|
|
q.put(False)
|
|
return
|
|
|
|
res, ok = execproc('ots-validator-checker', fontfile)
|
|
if not ok or res.find("didn't crash") == -1:
|
|
log.error('%s: checkfont ots-validator-checker: %s' % (fontfile, res))
|
|
q.put(False)
|
|
return
|
|
|
|
# ok
|
|
q.put(True)
|
|
|
|
|
|
def cmd_checkfont(self, argv):
|
|
argparser = argparse.ArgumentParser(
|
|
usage='%(prog)s checkfont <file> ...',
|
|
description='Verify integrity of font files')
|
|
|
|
argparser.add_argument('files', metavar='<file>', nargs='+',
|
|
help='Font files')
|
|
|
|
args = argparser.parse_args(argv)
|
|
q = Queue()
|
|
|
|
if len(args.files) < 2:
|
|
self.checkfont(args.files[0], q)
|
|
else:
|
|
procs = []
|
|
for fontfile in args.files:
|
|
p = Process(target=self.checkfont, args=(fontfile, q))
|
|
p.start()
|
|
procs.append(p)
|
|
for p in procs:
|
|
p.join()
|
|
|
|
for fontfile in args.files:
|
|
if not q.get():
|
|
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('--google-style', action='store_true',
|
|
help='Rename style names to Google Fonts standards.')
|
|
|
|
a('input', metavar='<file>',
|
|
help='Input font file')
|
|
|
|
args = argparser.parse_args(argv)
|
|
|
|
infile = args.input
|
|
outfile = args.output or infile
|
|
|
|
font = loadTTFont(infile)
|
|
editCount = 0
|
|
try:
|
|
if args.family:
|
|
editCount += 1
|
|
setFamilyName(font, args.family)
|
|
|
|
if args.google_style:
|
|
editCount += 1
|
|
renameStylesGoogleFonts(font)
|
|
|
|
if editCount == 0:
|
|
print("no rename options provided", file=sys.stderr)
|
|
argparser.print_help(sys.stderr)
|
|
sys.exit(1)
|
|
return
|
|
|
|
font.save(outfile)
|
|
finally:
|
|
font.close()
|
|
|
|
|
|
|
|
if __name__ == '__main__':
|
|
Main().main(sys.argv) |