summaryrefslogtreecommitdiff
path: root/misc/fontbuildlib
diff options
context:
space:
mode:
authorRasmus Andersson <rasmus@notion.se>2019-10-23 03:00:58 +0300
committerRasmus Andersson <rasmus@notion.se>2019-10-23 03:00:58 +0300
commitaa7ad2d7a0e255d4052c4999ec62d88c699586ac (patch)
tree54e8c771f6b0c5c09cedc3da4d9173c48968e580 /misc/fontbuildlib
parent9c444dededcb0787d562072a08c909ec463ea4b7 (diff)
downloadinter-aa7ad2d7a0e255d4052c4999ec62d88c699586ac.tar.xz
fontbuild: remove use of fontmake, simplifying things.
Diffstat (limited to 'misc/fontbuildlib')
-rw-r--r--misc/fontbuildlib/__init__.py1
-rw-r--r--misc/fontbuildlib/builder.py149
-rw-r--r--misc/fontbuildlib/glyph.py86
-rw-r--r--misc/fontbuildlib/info.py148
-rw-r--r--misc/fontbuildlib/name.py143
-rw-r--r--misc/fontbuildlib/util.py29
-rw-r--r--misc/fontbuildlib/version.py0
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