From aa7ad2d7a0e255d4052c4999ec62d88c699586ac Mon Sep 17 00:00:00 2001 From: Rasmus Andersson Date: Tue, 22 Oct 2019 17:00:58 -0700 Subject: fontbuild: remove use of fontmake, simplifying things. --- misc/fontbuild | 429 +++++------------------------------------- misc/fontbuildlib/__init__.py | 1 + misc/fontbuildlib/builder.py | 149 +++++++++++++++ misc/fontbuildlib/glyph.py | 86 +++++++++ misc/fontbuildlib/info.py | 148 +++++++++++++++ misc/fontbuildlib/name.py | 143 ++++++++++++++ misc/fontbuildlib/util.py | 29 +++ misc/fontbuildlib/version.py | 0 requirements.txt | 18 +- 9 files changed, 613 insertions(+), 390 deletions(-) create mode 100644 misc/fontbuildlib/__init__.py create mode 100644 misc/fontbuildlib/builder.py create mode 100644 misc/fontbuildlib/glyph.py create mode 100644 misc/fontbuildlib/info.py create mode 100644 misc/fontbuildlib/name.py create mode 100644 misc/fontbuildlib/util.py create mode 100644 misc/fontbuildlib/version.py 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 | 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='', 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='', - 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 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 | 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 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 -- cgit v1.2.3