Last active
February 27, 2020 23:05
-
-
Save anthrotype/2acbc67c75d6fa5833789ec01366a517 to your computer and use it in GitHub Desktop.
This file contains hidden or 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
| #!/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()) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Awesome, ty.
Can we generate a fea file and shove that into the UFO or do we have to "repair" this after going UFO:TTFont?