diff --git a/misc/fontbuild b/misc/fontbuild index 17280b42c..461ec7c20 100755 --- a/misc/fontbuild +++ b/misc/fontbuild @@ -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 | 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='', help='Output font file') - argparser.add_argument('--name', metavar='', - 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 ', + description='Rename family and/or styles of font' + ) + a = lambda *args, **kwargs: argparser.add_argument(*args, **kwargs) + + a('-o', '--output', metavar='', + help='Output font file. Defaults to input file (overwrite.)') + a('--family', metavar='', + help='Rename family to ') + a('--compact-style', action='store_true', + help='Rename style names to CamelCase. e.g. "Extra Bold Italic" -> "ExtraBoldItalic"') + a('input', metavar='', + 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) diff --git a/misc/tools/font_names.py b/misc/tools/font_names.py new file mode 100644 index 000000000..c321ca673 --- /dev/null +++ b/misc/tools/font_names.py @@ -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