Last active
September 18, 2022 12:36
-
-
Save arrowtype/4bcc0531b2950f0212e567a4f0e94ffa to your computer and use it in GitHub Desktop.
RoboFont script: generate faux-italic fonts as a starting point for designing an Italic companion to a Roman font
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
""" | |
DISCLAIMERS: | |
- WORK IN PROGRESS – works pretty well, but could use a few more improvements | |
- May or may not work for your particular project | |
- Always read scripts before you run them, and back up your work before you run scripts | |
DESCRIPTION: | |
A script to take the sources of Name Sans and output slanted versions of these, | |
for the purposes of A) prototyping & B) jumpstarting the italic drawings. | |
This is a RoboFont script. It could be adapted to run outside of RoboFont, with the main | |
changes being adapting a use of a RoboFont Mojo RPoint object. | |
Copies selected UFOs to an italic path, then: | |
1. Skews glyphs at a selected italic angle, configurable below | |
2. Skews round glyphs by less, configurable | |
3. Rotates round glyphs by the remainder of (italic angle - round skew) | |
4. Adds new extrema points | |
# TODO? correct for anchor alignment | |
Based largely on / started from the Slanter extension, by typemytype / Frederik Berlaen: | |
https://github.com/roboDocs/slanterRoboFontExtension/blob/57d7ac8ad9c91b0dce480cccb5a5dc2d4d4c51c4/Slanter.roboFontExt/lib/slanter.py | |
LICENSE: MIT (feel free to use / remix this script!) | |
""" | |
from vanilla.dialogs import * | |
from math import radians | |
from fontTools.misc.transform import Transform | |
from mojo.roboFont import RPoint | |
import os | |
import shutil | |
import math | |
# ------------------------------ | |
# set preferences below | |
openNewFonts = True | |
saveNewFonts = True | |
addExtremesToNewFonts = True | |
outputFolder = "faux-italics" | |
italicAngle = 11.31 # (Clockwise is positive, here.) this will be flipped to negative when entered into font info | |
setSkew = italicAngle | |
setRotation = 0 | |
ucRounds = "B C D G J O P R S U OE Ohorn Omega IJ zero two three five six eight nine zero.onum two.onum three.onum five.onum six.onum eight.onum nine.onum percent perthousand at ampersand section dollar cent sterling Euro copyright degree dollar.open cent.open question questiondown C.RECT D.RECT G.RECT O.RECT P.RECT R.RECT U.RECT OE.RECT Ohorn.RECT Germandbls.RECT R.grot two.tnum three.tnum five.tnum two.onum_tnum D.sups G.sups O.sups R.sups S.sups a.sups d.sups e.sups g.sups h.sups m.sups n.sups o.sups s.sups u.sups U.sups brevecomb ringcomb partialdiff infinity registered two.onum_tnum three.onum_tnum five.onum_tnum asterisk".split(" ") | |
lcRounds = "a b c d e g h m n o p q s u ohorn a.italic g.text germandbls ae eth oe germandbls.alt zero.dnom two.dnom three.dnom five.dnom six.dnom eight.dnom nine.dnom".split(" ") | |
# TODO: check on whether to add additional category for small, high-up glyphs: "D.sups G.sups O.sups R.sups S.sups a.sups d.sups e.sups g.sups h.sups m.sups n.sups o.sups s.sups u.sups U.sups" | |
rounds = ucRounds + lcRounds | |
roundRotatation = 7.54 | |
roundSkew = italicAngle - roundRotatation | |
# TODO: translate rounds to keep proper sidebearings/spacing | |
dialogMessage = "Select UFOs to copy into faux-italics" | |
# ------------------------------ | |
# Function mostly copied from the Slanter extension | |
def getGlyph(glyph, font, skew, rotation, addComponents=False, skipComponents=False, addExtremes=False): | |
skew = radians(skew) | |
rotation = radians(-rotation) | |
# print(glyph.name) | |
# To solve: "ValueError: Guideline names must be at least one character long." | |
for guideline in glyph.guidelines: | |
try: | |
if guideline.name != "": | |
pass | |
except ValueError: | |
glyph.clearGuidelines() | |
dest = glyph.copy() | |
if not addComponents: | |
for component in dest.components: | |
pointPen = DecomposePointPen(glyph.layer, dest.getPointPen(), component.transformation) | |
component.drawPoints(pointPen) | |
dest.removeComponent(component) | |
for contour in list(dest): | |
if contour.open: | |
dest.removeContour(contour) | |
if skew == 0 and rotation == 0: | |
return dest | |
for contour in dest: | |
for bPoint in contour.bPoints: | |
bcpIn = bPoint.bcpIn | |
bcpOut = bPoint.bcpOut | |
if bcpIn == (0, 0): | |
continue | |
if bcpOut == (0, 0): | |
continue | |
if bcpIn[0] == bcpOut[0] and bcpIn[1] != bcpOut[1]: | |
bPoint.anchorLabels = ["extremePoint"] | |
if rotation and bcpIn[0] != bcpOut[0] and bcpIn[1] == bcpOut[1]: | |
bPoint.anchorLabels = ["extremePoint"] | |
cx, cy = 0, 0 | |
box = glyph.bounds | |
if box: | |
cx = box[0] + (box[2] - box[0]) * .5 | |
cy = box[1] + (box[3] - box[1]) * .5 | |
# cy = font.info.xHeight * 0.5 | |
t = Transform() | |
t = t.skew(skew) | |
t = t.translate(cx, cy).rotate(rotation).translate(-cx, -cy) | |
if not skipComponents: | |
dest.transformBy(tuple(t)) | |
else: | |
for contour in dest.contours: | |
contour.transformBy(tuple(t)) | |
# this seems to work !!! | |
for component in dest.components: | |
# get component center | |
_box = glyph.layer[component.baseGlyph].bounds | |
if not _box: | |
continue | |
_cx = _box[0] + (_box[2] - _box[0]) * .5 | |
_cy = _box[1] + (_box[3] - _box[1]) * .5 | |
# calculate origin in relation to base glyph | |
dx = cx - _cx | |
dy = cy - _cy | |
# create transformation matrix | |
tt = Transform() | |
tt = tt.skew(skew) | |
tt = tt.translate(dx, dy).rotate(rotation).translate(-dx, -dy) | |
# apply transformation matrix to component offset | |
P = RPoint() | |
P.position = component.offset | |
P.transformBy(tuple(tt)) | |
# set component offset position | |
component.offset = P.position | |
# check if "add extremes" is set to True | |
if addExtremes: | |
dest.extremePoints(round=0) | |
for contour in dest: | |
for point in contour.points: | |
if "extremePoint" in point.labels: | |
point.selected = True | |
point.smooth = True | |
else: | |
point.selected = False | |
dest.removeSelection() | |
dest.round() | |
return dest | |
# Function adapted from the Slanter extension | |
def generateFont(fontToCopy): | |
outFont = RFont(showInterface=False) | |
outFont.info.update(fontToCopy.info.asDict()) | |
outFont.features.text = fontToCopy.features.text | |
outFont.info.italicAngle = -italicAngle | |
for glyph in fontToCopy: | |
outFont.newGlyph(glyph.name) | |
outGlyph = outFont[glyph.name] | |
outGlyph.width = glyph.width | |
outGlyph.unicodes = glyph.unicodes | |
if glyph.name not in rounds: | |
resultGlyph = getGlyph(glyph, font, setSkew, setRotation, addComponents=True, skipComponents=True, addExtremes=addExtremesToNewFonts) | |
else: | |
resultGlyph = getGlyph(glyph, font, roundSkew, roundRotatation, addComponents=True, skipComponents=True, addExtremes=addExtremesToNewFonts) | |
outGlyph.appendGlyph(resultGlyph) | |
# copy glyph order | |
outFont.templateGlyphOrder = fontToCopy.templateGlyphOrder | |
# copy groups & kerning | |
outFont.groups.update(fontToCopy.groups.asDict()) | |
outFont.kerning.update(fontToCopy.kerning.asDict()) | |
# quick/lazy update to relative feature link | |
if "sparse" not in fontToCopy.path: | |
outFont.features.text = "include(../../features/features.fea);" | |
outFont.info.styleName = outFont.info.styleName + " Italic" | |
return outFont | |
def fixRoundOffset(font, ucRoundBasis="O", lcRoundBasis="o", roundOffset=False): | |
""" | |
Fix horizontal position of rounded glyphs, based on centering O and o. | |
""" | |
try: | |
# calculate offset value with baseGlyph | |
ucBaseLeftMargin = (font[ucRoundBasis].angledLeftMargin + font[ucRoundBasis].angledRightMargin) / 2.0 | |
ucOffset = -font[ucRoundBasis].angledLeftMargin + ucBaseLeftMargin | |
# if there’s no "O" / ucRoundBasis glyph | |
except KeyError: | |
try: | |
ucRoundBasis = "at" | |
ucBaseLeftMargin = (font[ucRoundBasis].angledLeftMargin + font[ucRoundBasis].angledRightMargin) / 2.0 | |
ucOffset = -font[ucRoundBasis].angledLeftMargin + ucBaseLeftMargin | |
except: | |
print(f"WARNING: can’t correct x position of uppercase rounds in {font}") | |
pass | |
try: | |
# calculate offset value with baseGlyph | |
lcBaseLeftMargin = (font[lcRoundBasis].angledLeftMargin + font[lcRoundBasis].angledRightMargin) / 2.0 | |
lcOffset = -font[lcRoundBasis].angledLeftMargin + lcBaseLeftMargin | |
except KeyError: | |
try: | |
lcRoundBasis = "e" | |
lcBaseLeftMargin = (font[ucRoundBasis].angledLeftMargin + font[ucRoundBasis].angledRightMargin) / 2.0 | |
lcOffset = -font[ucRoundBasis].angledLeftMargin + ucBaseLeftMargin | |
except: | |
print(f"WARNING: can’t correct x position of lowercase rounds in {font}") | |
pass | |
try: | |
# round offset value | |
if roundOffset: | |
ucOffset = round(ucOffset) | |
lcOffset = round(lcOffset) | |
for glyph in font: | |
if glyph.name in ucRounds: | |
glyph.move((ucOffset, 0)) | |
if glyph.name in lcRounds: | |
glyph.move((lcOffset, 0)) | |
# also offset anchors, which will be further corrected for slanting, below, in correctAnchors() | |
for anchor in glyph.anchors: | |
if glyph.name in ucRounds: | |
anchor.x -= ucOffset | |
if glyph.name in lcRounds: | |
anchor.x -= lcOffset | |
except UnboundLocalError: | |
print("skipping circular glyph offset") | |
pass | |
def addOvershootGuidelines(font): | |
""" | |
Add guidelines to assist in making overshoots exactly vertically aligned, which is helpful to autohinting results. | |
""" | |
font.appendGuideline((0, font.info.capHeight + 20), 0, name="overshoot") | |
font.appendGuideline((0, font.info.ascender + 20), 0, name="overshoot") | |
font.appendGuideline((0, font.info.xHeight + 20), 0, name="overshoot") | |
font.appendGuideline((0, font.info.descender - 20), 0, name="overshoot") | |
font.appendGuideline((0, -20), 0, name="overshoot") | |
def getFlippedComponents(font): | |
flippedComponents = [] | |
for g in font: | |
for component in g.components: | |
if component.naked().transformation: | |
# flipped horizontal | |
if component.naked().transformation[0] == -1: | |
flippedComponents.append(g.name) | |
# flipped vertical | |
if component.naked().transformation[3] == -1: | |
flippedComponents.append(g.name) | |
return list(set(flippedComponents)) | |
def correctItalicOffset(font, offsetBasisGlyph="H", roundOffset=False): | |
""" | |
https://robofont.com/RF4.1/documentation/tutorials/making-italic-fonts/#applying-the-italic-slant-offset-after-drawing | |
""" | |
if offsetBasisGlyph not in font.keys(): | |
if "e" in font.keys(): | |
offsetBasisGlyph = "e" | |
elif "o" in font.keys(): | |
offsetBasisGlyph = "o" | |
# calculate offset value with offsetBasisGlyph | |
baseLeftMargin = (font[offsetBasisGlyph].angledLeftMargin + font[offsetBasisGlyph].angledRightMargin) / 2.0 | |
offset = -font[offsetBasisGlyph].angledLeftMargin + baseLeftMargin | |
# round offset value | |
if roundOffset and offset != 0: | |
offset = round(offset) | |
# get flipped components | |
flippedComponents = getFlippedComponents(font) | |
# apply offset to all glyphs in font | |
if offset and font[offsetBasisGlyph].angledLeftMargin and len(font.keys()): | |
# get reverse components dict | |
componentMap = font.getReverseComponentMapping() | |
# apply offset to all glyphs in font | |
for glyphName in font.keys(): | |
with font[glyphName].undo(): | |
font[glyphName].move((offset, 0)) | |
# if the glyph is used as a component, revert component offset | |
for composedGlyph in componentMap.get(glyphName, []): | |
for component in font[composedGlyph].components: | |
if component.baseGlyph == glyphName: | |
# make sure it's not a flipped component | |
if glyphName not in flippedComponents: | |
component.move((-offset, 0)) | |
# done with glyph | |
font[glyphName].update() | |
# fix flipped components | |
for glyphName in flippedComponents: | |
for component in font[glyphName].components: | |
# offset flipped components twice: | |
# baseGlyph offset + offset in the wrong direction | |
component.move((offset*2, 0)) | |
# fix glyphs which use the flipped component as a component | |
for composedGlyph in componentMap.get(glyphName, []): | |
for component in font[composedGlyph].components: | |
if component.baseGlyph == glyphName: | |
component.move((-offset, 0)) | |
# done with glyph | |
font[glyphName].update() | |
def correctAnchors(font): | |
""" | |
Correct anchor x position for italic slant. | |
""" | |
for glyph in font: | |
for anchor in glyph.anchors: | |
anchor.x += math.tan(math.radians(-font.info.italicAngle)) * anchor.y | |
def copySpacing(originalFont, slantedFont): | |
""" | |
We want to easily correct the positioning and spacing of slanted glyphs, | |
so this just copies the spacing from the original font. | |
TODO: determine whether it is better to copy the left margin and glyph width, or the left and right margins... | |
""" | |
for g in slantedFont: | |
try: | |
g.angledLeftMargin = originalFont[g.name].leftMargin | |
# g.angledRightMargin = originalFont[g.name].rightMargin | |
g.width = originalFont[g.name].width | |
except (KeyError, TypeError): | |
print(f"Could copy spacing of /{g.name} in {originalFont}") | |
# Get input font paths | |
inputFonts = getFile(dialogMessage, allowsMultipleSelection=True, fileTypes=["ufo"]) | |
# Go through input paths & use to generate slanted fonts | |
for fontPath in inputFonts: | |
font = OpenFont(fontPath, showInterface=False) | |
print("\n\n\n\n--------------------------------") | |
print(font.info.styleName, "\n\n") | |
# set up paths, clear existing UFOs | |
fontDir, fontFile = os.path.split(fontPath) | |
italicDir = fontDir + "/" + outputFolder | |
if not os.path.exists(italicDir): | |
os.makedirs(italicDir) | |
slantedFontPath = italicDir + "/" + f"{fontFile.replace('.ufo','_Italic.ufo')}" | |
# delete faux-italic UFO if it already exists, to avoid filesystem clashes | |
if os.path.exists(slantedFontPath): | |
shutil.rmtree(slantedFontPath) | |
# Generate faux italic font | |
slantedFont = generateFont(font) | |
# fix x positioning of round glyphs | |
fixRoundOffset(slantedFont) | |
# added guidelines for overshoots | |
addOvershootGuidelines(slantedFont) | |
# add italic offset | |
italicSlantOffset = math.tan(slantedFont.info.italicAngle * math.pi / 180) * (slantedFont.info.xHeight * 0.5) | |
slantedFont.lib["com.typemytype.robofont.italicSlantOffset"] = italicSlantOffset | |
# correct for italic offset | |
correctItalicOffset(slantedFont) | |
correctAnchors(slantedFont) | |
copySpacing(font, slantedFont) | |
# TODO? correct for anchor alignment | |
if saveNewFonts: | |
slantedFont.save(slantedFontPath) | |
if openNewFonts: | |
slantedFont.openInterface() | |
else: | |
slantedFont.close() | |
font.close() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment