Skip to content

Instantly share code, notes, and snippets.

@anthrotype
Last active February 27, 2020 23:05
Show Gist options
  • Select an option

  • Save anthrotype/2acbc67c75d6fa5833789ec01366a517 to your computer and use it in GitHub Desktop.

Select an option

Save anthrotype/2acbc67c75d6fa5833789ec01366a517 to your computer and use it in GitHub Desktop.
#!/usr/bin/env python3
"""Create a COLR/CPAL font from a set of SVGs.
Sample usage:
make_colr_font.py -o font.ttf $(find ~/oss/noto-emoji/svg -name '*.svg' | head -10)
"""
import argparse
from collections import namedtuple
import itertools
import logging
import os
import sys
__requires__ = ["nanosvg", "ufoLib2", "ufo2ft"]
from nanosvg.svg import SVG
from nanosvg.svg_pathops import skia_path
from fontTools.misc.transform import Transform
from fontTools.pens.transformPen import TransformPen
import ufoLib2
import ufo2ft
logger = logging.getLogger()
# from https://github.com/adobe/svg-native-viewer/blob/master/svgnative/src/CSSColorKeywords.h
CSS_COLORS = {
"aliceblue": (240, 248, 255),
"antiquewhite": (250, 235, 215),
"aqua": (0, 255, 255),
"aquamarine": (127, 255, 212),
"azure": (240, 255, 255),
"beige": (245, 245, 220),
"bisque": (255, 228, 196),
"black": (0, 0, 0),
"blanchedalmond": (255, 235, 205),
"blue": (0, 0, 255),
"blueviolet": (138, 43, 226),
"brown": (165, 42, 42),
"burlywood": (222, 184, 135),
"cadetblue": (95, 158, 160),
"chartreuse": (127, 255, 0),
"chocolate": (210, 105, 30),
"coral": (255, 127, 80),
"cornflowerblue": (100, 149, 237),
"cornsilk": (255, 248, 220),
"crimson": (220, 20, 60),
"cyan": (0, 255, 255),
"darkblue": (0, 0, 139),
"darkcyan": (0, 139, 139),
"darkgoldenrod": (184, 134, 11),
"darkgray": (169, 169, 169),
"darkgreen": (0, 100, 0),
"darkgrey": (169, 169, 169),
"darkkhaki": (189, 183, 107),
"darkmagenta": (139, 0, 139),
"darkolivegreen": (85, 107, 47),
"darkorange": (255, 140, 0),
"darkorchid": (153, 50, 204),
"darkred": (139, 0, 0),
"darksalmon": (233, 150, 122),
"darkseagreen": (143, 188, 143),
"darkslateblue": (72, 61, 139),
"darkslategray": (47, 79, 79),
"darkslategrey": (47, 79, 79),
"darkturquoise": (0, 206, 209),
"darkviolet": (148, 0, 211),
"deeppink": (255, 20, 147),
"deepskyblue": (0, 191, 255),
"dimgray": (105, 105, 105),
"dimgrey": (105, 105, 105),
"dodgerblue": (30, 144, 255),
"firebrick": (178, 34, 34),
"floralwhite": (255, 250, 240),
"forestgreen": (34, 139, 34),
"fuchsia": (255, 0, 255),
"gainsboro": (220, 220, 220),
"ghostwhite": (248, 248, 255),
"gold": (255, 215, 0),
"goldenrod": (218, 165, 32),
"gray": (128, 128, 128),
"green": (0, 128, 0),
"greenyellow": (173, 255, 47),
"grey": (128, 128, 128),
"honeydew": (240, 255, 240),
"hotpink": (255, 105, 180),
"indianred": (205, 92, 92),
"indigo": (75, 0, 130),
"ivory": (255, 255, 240),
"khaki": (240, 230, 140),
"lavender": (230, 230, 250),
"lavenderblush": (255, 240, 245),
"lawngreen": (124, 252, 0),
"lemonchiffon": (255, 250, 205),
"darkslateblue": (72, 61, 139),
"lightcoral": (240, 128, 128),
"lightcyan": (224, 255, 255),
"lightgoldenrodyellow": (250, 250, 210),
"lightgray": (211, 211, 211),
"lightgreen": (144, 238, 144),
"lightgrey": (211, 211, 211),
"lightpink": (255, 182, 193),
"lightsalmon": (255, 160, 122),
"lightseagreen": (32, 178, 170),
"lightskyblue": (135, 206, 250),
"lightslategray": (119, 136, 153),
"lightslategrey": (119, 136, 153),
"lightsteelblue": (176, 196, 222),
"lightyellow": (255, 255, 224),
"lime": (0, 255, 0),
"limegreen": (50, 205, 50),
"linen": (250, 240, 230),
"magenta": (255, 0, 255),
"maroon": (128, 0, 0),
"mediumaquamarine": (102, 205, 170),
"mediumblue": (0, 0, 205),
"mediumorchid": (186, 85, 211),
"mediumpurple": (147, 112, 219),
"mediumseagreen": (60, 179, 113),
"mediumslateblue": (123, 104, 238),
"mediumspringgreen": (0, 250, 154),
"mediumturquoise": (72, 209, 204),
"mediumvioletred": (199, 21, 133),
"midnightblue": (25, 25, 112),
"mintcream": (245, 255, 250),
"mistyrose": (255, 228, 225),
"moccasin": (255, 228, 181),
"navajowhite": (255, 222, 173),
"navy": (0, 0, 128),
"oldlace": (253, 245, 230),
"olive": (128, 128, 0),
"olivedrab": (107, 142, 35),
"orange": (255, 165, 0),
"orangered": (255, 69, 0),
"orchid": (218, 112, 214),
"palegoldenrod": (238, 232, 170),
"palegreen": (152, 251, 152),
"paleturquoise": (175, 238, 238),
"palevioletred": (219, 112, 147),
"papayawhip": (255, 239, 213),
"peachpuff": (255, 218, 185),
"peru": (205, 133, 63),
"pink": (255, 192, 203),
"plum": (221, 160, 221),
"powderblue": (176, 224, 230),
"purple": (128, 0, 128),
"rebeccapurple": (102, 51, 153),
"red": (255, 0, 0),
"rosybrown": (188, 143, 143),
"royalblue": (65, 105, 225),
"saddlebrown": (139, 69, 19),
"salmon": (250, 128, 114),
"sandybrown": (244, 164, 96),
"seagreen": (46, 139, 87),
"seashell": (255, 245, 238),
"sienna": (160, 82, 45),
"silver": (192, 192, 192),
"skyblue": (135, 206, 235),
"slateblue": (106, 90, 205),
"slategray": (112, 128, 144),
"slategrey": (112, 128, 144),
"snow": (255, 250, 250),
"springgreen": (0, 255, 127),
"steelblue": (70, 130, 180),
"tan": (210, 180, 140),
"teal": (0, 128, 128),
"thistle": (216, 191, 216),
"tomato": (255, 99, 71),
"turquoise": (64, 224, 208),
"violet": (238, 130, 238),
"wheat": (245, 222, 179),
"white": (255, 255, 255),
"whitesmoke": (245, 245, 245),
"yellow": (255, 255, 0),
"yellowgreen": (154, 205, 50),
}
class Color(namedtuple("Color", "red green blue alpha")):
@classmethod
def fromstring(cls, s, alpha=1.0):
if s.startswith("#"):
ss = s[1:]
if len(ss) == 3:
red = int(ss[0], 16)
green = int(ss[1], 16)
blue = int(ss[2], 16)
elif len(ss) == 6:
red = int(ss[0:2], 16)
green = int(ss[2:4], 16)
blue = int(ss[4:6], 16)
else:
raise ValueError(f"expected 3 or 6 hex digits, found {s!r}")
elif s in CSS_COLORS:
red, green, blue = CSS_COLORS[s]
elif s.startswith("rgb(") and s.endswith(")"):
ss = s[4:-1]
# accept either commas or space, not both
values = [v for v in ss.split("," if "," in ss else " ") if v]
if len(values) != 3:
raise ValueError(f"expected 3 rgb() values, found {len(values)}: {s!r}")
# round floats and clamp to [0..255] range
red, green, blue = (
max(0, min(255, i)) for i in (round(float(v)) for v in values)
)
else:
raise ValueError(f"invalid or unsupported color string: {s!r}")
# UFO stores colors as RGBA tuples of decimal [0..1] floats. Here we round to 3
# decimal digits, which are sufficient to round-trip Fixed0.8 numbers
red = float(f"{red / 255:.03f}")
green = float(f"{green / 255:.03f}")
blue = float(f"{blue / 255:.03f}")
return (red, green, blue, alpha)
def iter_paths_and_paints(svg_file):
logging.info("parsing %s", svg_file)
svg = SVG.parse(svg_file).tonanosvg()
for i, shape in enumerate(svg.shapes()):
if shape.fill == "none":
logger.warning(f"invisible shape #{i} dropped: {shape!r}")
continue
opacity = float(shape.opacity) if shape.opacity else 1.0
try:
paint = Color.fromstring(shape.fill or "black", alpha=opacity)
except ValueError as e:
logger.error(e)
paint = Color.fromstring("black")
path = skia_path(shape)
yield (path, paint)
def get_or_create_new_color_layer(ufo, index):
layer_name = f"color{index}"
if layer_name not in ufo.layers:
return ufo.newLayer(layer_name)
return ufo.layers[layer_name]
def unicodes_from_name(glyph_name):
# TODO the 'emoji_uXXXX_XXXX' pattern is too noto-emoji specific. Maybe take an
# optional pattern
return [int(v, 16) for v in glyph_name.lstrip("emoji_u").split("_")]
def main(args=None):
parser = argparse.ArgumentParser()
parser.add_argument(
"input_svgs",
metavar="SVG_FILE",
nargs="+",
help="One or more .svg files containing paths. The file names must follow this"
" pattern: 'emoji_uXXXX[_XXXX]*' where XXXX are unicode codepoints.",
)
parser.add_argument(
"-o", "--output-path", default=None, help="path to output COLR/CPAL font"
)
parser.add_argument("-f", "--format", default="ttf", choices=["ufo", "ttf", "otf"])
parser.add_argument("--family-name", default=None)
parser.add_argument(
"--upem",
default=1000,
type=int,
help="Font Units Per Em (default: %(default)s)",
)
parser.add_argument(
"--svg-px",
default=128,
type=int,
help="the px size of SVG images (default: %(default)s)",
)
options = parser.parse_args(args)
logging.basicConfig(level="INFO")
# affine transform matrix to convert from SVG to Font coordinate space
# TODO: shouldn't we use viewBox or svg.height/width?
scale = float(options.upem) / options.svg_px
transform = Transform(scale, 0, 0, -scale, 0, options.upem)
# each SVG file represent one base glyph in the COLR font; for each glyph,
# get the list of layers associated with it; for each layer we have a tuple
# of (pathops.Path, Color). We use pathops.Path since it supports pen protocol
# (has a draw() method we can use with UFO Glyph pen).
glyph_color_layers = {}
for svg_file in options.input_svgs:
glyph_name = os.path.basename(os.path.splitext(svg_file)[0])
glyph_color_layers[glyph_name] = list(iter_paths_and_paints(svg_file))
# the sorted list of all unique colors used by all the SVG images
color_palette = sorted(
{c for g, layers in glyph_color_layers.items() for _, c in layers}
)
# creates a new empty UFO object
ufo = ufoLib2.Font()
# set various font metadata; see the full list of fontinfo attributes at
# http://unifiedfontobject.org/versions/ufo3/fontinfo.plist/
ufo.info.unitsPerEm = options.upem
if options.family_name is not None:
ufo.info.familyName = options.family_name
for glyph_name, layers in glyph_color_layers.items():
color_layer_map = []
# newGlyph creates and returns a new empty glyph in the Font's default layer
base_glyph = ufo.newGlyph(glyph_name)
# the Glyph.unicode property sets/gets the unicode codepoint (stored as decimal
# integer). Here I am inferring that from the SVG file name.
try:
unicodes = unicodes_from_name(glyph_name)
except ValueError:
logger.warning("failed to parse unicode from '%s'", glyph_name)
continue
# noto-emoji glyphs often comprise multiple unicode codepoints; while a UFO
# Glyph can be cmapped to multiple unicodes (Glyph.unicodes is a list),
# these are understood not as a sequence but as alternative mappings (e.g. the
# same glyph being mapped to different characters). For simplicity here I am
# assuming the svg filename only contains a single codepoint.
# There's a proposal to add support for storing UVS in UFO, see
# https://github.com/unified-font-object/ufo-spec/issues/79
if len(unicodes) > 1:
logger.warning("skipped unsupported ligature glyph '%s'", glyph_name)
continue
base_glyph.unicode = unicodes[0]
# for each color layer I get or create a new font-wide layer (in UFO, the Font
# has layers, not the Glyph; the Layer has Glyphs). Layer names must be unique,
# so I call them "color0", "color1", "color2"
for i, (path, paint) in enumerate(layers):
layer = get_or_create_new_color_layer(ufo, i)
# Layer.newGlyph method creates a new glyph in the given layer
glyph = layer.newGlyph(glyph_name)
# We use the fontTools TransformPen to wrap the drawing pen so we can
# map from the SVG space to the font units
pen = TransformPen(glyph.getPen(), transform)
path.draw(pen)
palette_id = color_palette.index(paint)
color_layer_map.append((layer.name, palette_id))
# each base glyph contains a list of (layer.name, color_palette_id) in z-order
base_glyph.lib[ufo2ft.constants.COLOR_LAYER_MAPPING_KEY] = color_layer_map
# the list of color palettes is global, here we only have one palette.
ufo.lib[ufo2ft.constants.COLOR_PALETTES_KEY] = [color_palette]
# the filter below is required to enable the copying of the color layers
# to standalone glyphs in the default glyph set used to build the TTFont
# TODO(anthrotype) Make this automatic somehow?
ufo.lib[ufo2ft.constants.FILTERS_KEY] = [
{"name": "Explode Color Layer Glyphs", "pre": True}
]
if options.output_path:
if options.format == "ufo":
ufo.save(options.output_path, overwrite=True)
else:
# I use skia-pathops to remove overlaps (i.e. simplify self-overlapping
# paths). The default overlapsBackend (booleanOperations) does not support
# paths that contain quadratic bezier curves (qcurve), which may appear
# when we pass through nanosvg (e.g. arcs or stroked paths).
if options.format == "ttf":
otf = ufo2ft.compileTTF(ufo, overlapsBackend="pathops")
elif options.format == "otf":
otf = ufo2ft.compileOTF(ufo, overlapsBackend="pathops")
else:
raise AssertionError(options.format)
otf.save(options.output_path)
if __name__ == "__main__":
sys.exit(main())
@rsheeter

Copy link
Copy Markdown

Awesome, ty.

    # Glyph can be cmapped to multiple unicodes (Glyph.unicodes is a list),

Can we generate a fea file and shove that into the UFO or do we have to "repair" this after going UFO:TTFont?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment