Skip to content

Commit

Permalink
fontbuild: remove use of fontmake, simplifying things.
Browse files Browse the repository at this point in the history
  • Loading branch information
rsms committed Oct 23, 2019
1 parent 9c444de commit aa7ad2d
Show file tree
Hide file tree
Showing 9 changed files with 613 additions and 390 deletions.
429 changes: 50 additions & 379 deletions misc/fontbuild

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions misc/fontbuildlib/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .builder import FontBuilder
149 changes: 149 additions & 0 deletions misc/fontbuildlib/builder.py
Original file line number Diff line number Diff line change
@@ -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

86 changes: 86 additions & 0 deletions misc/fontbuildlib/glyph.py
Original file line number Diff line number Diff line change
@@ -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)
148 changes: 148 additions & 0 deletions misc/fontbuildlib/info.py
Original file line number Diff line number Diff line change
@@ -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())
Loading

0 comments on commit aa7ad2d

Please sign in to comment.