diff options
author | Rasmus Andersson <rasmus@notion.se> | 2019-10-23 03:00:58 +0300 |
---|---|---|
committer | Rasmus Andersson <rasmus@notion.se> | 2019-10-23 03:00:58 +0300 |
commit | aa7ad2d7a0e255d4052c4999ec62d88c699586ac (patch) | |
tree | 54e8c771f6b0c5c09cedc3da4d9173c48968e580 /misc/fontbuildlib | |
parent | 9c444dededcb0787d562072a08c909ec463ea4b7 (diff) | |
download | inter-aa7ad2d7a0e255d4052c4999ec62d88c699586ac.tar.xz |
fontbuild: remove use of fontmake, simplifying things.
Diffstat (limited to 'misc/fontbuildlib')
-rw-r--r-- | misc/fontbuildlib/__init__.py | 1 | ||||
-rw-r--r-- | misc/fontbuildlib/builder.py | 149 | ||||
-rw-r--r-- | misc/fontbuildlib/glyph.py | 86 | ||||
-rw-r--r-- | misc/fontbuildlib/info.py | 148 | ||||
-rw-r--r-- | misc/fontbuildlib/name.py | 143 | ||||
-rw-r--r-- | misc/fontbuildlib/util.py | 29 | ||||
-rw-r--r-- | misc/fontbuildlib/version.py | 0 |
7 files changed, 556 insertions, 0 deletions
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 <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 + 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<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)
\ 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 --- /dev/null +++ b/misc/fontbuildlib/version.py |