#!/usr/bin/env python3 """ Builds one project's icons.css (and its favicon.svg) from that project's icons.json. The icon set belongs to the project, not to this tool: every project lists the icons it actually uses, so every project gets its own subset. There is no global artifact to build and copy - the rollout generates, per target. Reads the semantic names from the set file, looks their codepoints up in the licensed fontawesome.min.css of the system package, subsets the face down to exactly those glyphs and writes a stylesheet with the font embedded as a base64 data: URI. Inline on purpose: a tool baked into one file (sync.php on a remote server) carries no assets next to it. The favicon is drawn from the same font - the glyph outline goes into an SVG as a path, so it stays sharp at any size and takes the colour the set names. Behind it the set may put a plate: a square in a colour of its own, rounded from square to circle by a percentage, with the glyph scaled into it. Usage: python subset.py --set /css/icons.json --out /css --font --css [--family "systopic icons"] """ import argparse import base64 import io import json import os import re import sys # .fa-trash-alt:before,.fa-trash-can:before{content:"\f2ed"} RULE = re.compile(r'(?:\.fa-[a-z0-9-]+:before,)*\.fa-[a-z0-9-]+:before\{content:"([^"]+)"\}') NAME = re.compile(r'\.fa-([a-z0-9-]+):before') BACKSLASH = chr(92) DEFAULT_COLOR = 'orange' def fail(message): print('icons: ' + message, file=sys.stderr) raise SystemExit(1) def percent(value, fallback, low=0.0, high=100.0): """A percentage out of the set file, clamped. 20, "20" and "20%" all read.""" if value is None: return fallback try: number = float(str(value).strip().rstrip('%')) except ValueError: return fallback return min(high, max(low, number)) def plate_of(favicon): """ The optional plate behind the glyph, read off the set's favicon entry: "background" its colour - a third one, next to the icon's own "radius" how round its corners are, 0 a square, 100 a circle "scale" how much of the plate the glyph fills, in percent NULL when the set asks for neither a colour nor a scale, and then the SVG is the bare glyph it always was. @return dict or None """ color = str(favicon.get('background') or favicon.get('bg') or '') radius = percent(favicon.get('radius'), 0.0) scale = percent(favicon.get('scale'), 100.0, low=1.0) if not color and scale == 100.0: return None return {'color': color, 'radius': radius, 'scale': scale} def codepoints(css_path): """every fa name of the licensed stylesheet -> its codepoint""" with open(css_path, encoding='utf-8') as handle: css = handle.read() found = {} for match in RULE.finditer(css): value = match.group(1) if not value.startswith(BACKSLASH): continue point = int(value[1:], 16) for name in NAME.findall(match.group(0)): found.setdefault(name, point) return found def contours(glyph_set, glyph_name): """ The glyph's closed shapes, each with its own bounding box. A recording pen replays every drawing command; a new moveTo starts a new contour. Font Awesome has no duotone face here, but an icon drawn as separate shapes - two arrows forming a circle - can still be coloured per shape, which is what makes a two-tone favicon possible. @return list of (bounds, commands), the topmost shape first """ from fontTools.pens.boundsPen import BoundsPen from fontTools.pens.recordingPen import RecordingPen recording = RecordingPen() glyph_set[glyph_name].draw(recording) shapes, current = [], [] for op, args in recording.value: if op == 'moveTo' and current: shapes.append(current) current = [] current.append((op, args)) if current: shapes.append(current) out = [] for commands in shapes: pen = BoundsPen(glyph_set) for op, args in commands: getattr(pen, op)(*args) if pen.bounds is not None: out.append((pen.bounds, commands)) # top first, so the colours are listed the way the icon reads out.sort(key=lambda shape: -(shape[0][1] + shape[0][3])) return out def inside(inner, outer): """is one bounding box wholly within another? then it is a hole""" return (inner[0] >= outer[0] and inner[1] >= outer[1] and inner[2] <= outer[2] and inner[3] <= outer[3] and inner != outer) def area(bounds): return (bounds[2] - bounds[0]) * (bounds[3] - bounds[1]) def groups(glyph_set, glyph_name): """ The glyph's shapes, each hole grouped with the shape it sits in. A gear is one outline with three counters cut out of it - one group, one colour. A server is two boxes with their own slots - two groups, so it can take two colours. Grouping is what makes the distinction: within a group every contour is drawn into the same path, so the non-zero fill rule still cuts the holes out. @return list of (bounds, [commands]), the topmost group first """ shapes = contours(glyph_set, glyph_name) # the outermost shape a contour sits in - that is the one it belongs to def root(index): seen = {index} while True: bounds = shapes[index][0] holding = [j for j, (other, _) in enumerate(shapes) if j not in seen and inside(bounds, other)] if not holding: return index index = max(holding, key=lambda j: area(shapes[j][0])) seen.add(index) collected = {} for i in range(len(shapes)): collected.setdefault(root(i), []).append(i) out = [] for parent, members in collected.items(): members.sort() bounds = shapes[parent][0] commands = [command for i in members for command in shapes[i][1]] out.append((bounds, commands)) out.sort(key=lambda group: -(group[0][1] + group[0][3])) return out def svg_document(layers, plate=None): """ Wraps drawn layers into an SVG. Font coordinates run y-up and sit on the baseline, SVG runs y-down - scale(1,-1) turns the paths over, and the viewBox is their common bounding box, squared off around its centre so a wide icon is not stretched into the tab. A plate widens that viewBox rather than shrinking the glyph: at scale 90 the glyph square is 90% of the visible square, so the remaining tenth is the margin around it and the paths stay exactly as drawn. The rect fills the viewBox, so its corner radius is a share of that square - half the side is already a circle, which is what radius 100 has to mean. @param layers list of (bounds, color, path data), back to front @param plate dict from plate_of(), or None for the bare glyph """ x0 = min(b[0] for b, _, _ in layers) y0 = min(b[1] for b, _, _ in layers) x1 = max(b[2] for b, _, _ in layers) y1 = max(b[3] for b, _, _ in layers) side = max(x1 - x0, y1 - y0) outer = side * 100.0 / plate['scale'] if plate else side left = (x0 + x1) / 2 - outer / 2 top = -((y0 + y1) / 2 + outer / 2) body = '' if plate and plate['color']: corner = outer * plate['radius'] / 200.0 body += ('' % (left, top, outer, outer, corner, corner, plate['color'])) body += ''.join( '' % (color, data) for _, color, data in layers ) return ('%s' % (left, top, outer, outer, body)) # font awesome puts a duotone icon's second layer one plane up SECONDARY_OFFSET = 0x100000 def duotone_layers(font_path, point, colors): """ The real thing: font awesome's duotone faces carry two glyphs per icon, -primary at the icon's codepoint and -secondary at the same codepoint plus 0x100000. The colours are listed primary first, the way font awesome names them; drawn secondary first, so it sits behind. NULL when this face has no pair for the icon - the caller then falls back to splitting the regular glyph by shape. """ from fontTools.pens.boundsPen import BoundsPen from fontTools.pens.svgPathPen import SVGPathPen from fontTools.ttLib import TTFont font = TTFont(font_path) cmap = font.getBestCmap() if point not in cmap or point + SECONDARY_OFFSET not in cmap: return None glyph_set = font.getGlyphSet() layers = [] for cp, color in ((point + SECONDARY_OFFSET, colors[1]), (point, colors[0])): glyph = glyph_set[cmap[cp]] bounds = BoundsPen(glyph_set) glyph.draw(bounds) if bounds.bounds is None: continue pen = SVGPathPen(glyph_set) glyph.draw(pen) layers.append((bounds.bounds, color, pen.getCommands())) return layers or None def shape_layers(glyph_set, glyph_name, colors): """ The fallback for an icon with no duotone glyphs: split the regular glyph into its shape groups, top to bottom, one colour each. The last colour repeats for anything left over. """ from fontTools.pens.svgPathPen import SVGPathPen shapes = groups(glyph_set, glyph_name) if not shapes: return None if len(colors) > len(shapes): fail('%d colours, but this icon has neither duotone glyphs nor that' ' many separately colourable shapes (%d) - run' ' builder/shapes.py to see them' % (len(colors), len(shapes))) layers = [] for index, (bounds, commands) in enumerate(shapes): pen = SVGPathPen(glyph_set) for op, args in commands: getattr(pen, op)(*args) layers.append((bounds, colors[min(index, len(colors) - 1)], pen.getCommands())) return layers def favicon_svg(glyph_set, glyph_name, colors, duotone_font=None, point=None, plate=None): """ One colour draws the whole glyph as a single path. Two or more use the duotone face when it has this icon, and fall back to the shape split when it does not. The plate, when the set asks for one, goes behind either. @return (svg, how) or (None, None) """ from fontTools.pens.boundsPen import BoundsPen from fontTools.pens.svgPathPen import SVGPathPen if len(colors) > 1: layers = None if duotone_font and point is not None: layers = duotone_layers(duotone_font, point, colors) if layers is not None: return svg_document(layers, plate), 'duotone' layers = shape_layers(glyph_set, glyph_name, colors) return (svg_document(layers, plate), 'formen') if layers else (None, None) glyph = glyph_set[glyph_name] bounds = BoundsPen(glyph_set) glyph.draw(bounds) if bounds.bounds is None: return None, None pen = SVGPathPen(glyph_set) glyph.draw(pen) return (svg_document([(bounds.bounds, colors[0], pen.getCommands())], plate), 'einfarbig') def stylesheet(family, payload, points, font_name): lines = [ '/* GENERATED by tools/icons - do not edit, change icons.json and roll out.', ' * Font Awesome Pro 6 (Commercial License, Fonticons Inc.), %s,' % font_name, ' * subset to the %d icons this project uses and embedded so the' % len(points), ' * stylesheet stands alone - a tool baked into one file has no assets. */', '@font-face {', '\tfont-family: "%s";' % family, '\tfont-style: normal;', '\tfont-weight: 400;', '\tfont-display: block;', '\tsrc: url(data:font/woff2;charset=utf-8;base64,%s) format("woff2");' % payload, '}', '', '.icon {', '\tfont-family: "%s";' % family, '\tfont-style: normal;', '\tfont-weight: 400;', '\tfont-variant: normal;', '\tline-height: 1;', '\tdisplay: inline-block;', '\ttext-align: center;', '\ttext-decoration: none;', '\ttext-rendering: auto;', '\t-webkit-font-smoothing: antialiased;', '\t-moz-osx-font-smoothing: grayscale;', '}', '', ] for name in sorted(points): lines.append('.icon.%s::before { content: "%s%x" }' % (name, BACKSLASH, points[name])) lines.append('') return '\n'.join(lines) def main(): parser = argparse.ArgumentParser(add_help=True) parser.add_argument('--set', dest='set_file', required=True) parser.add_argument('--out', dest='out_dir', required=True) parser.add_argument('--font', required=True) parser.add_argument('--css', required=True) parser.add_argument('--duotone', default='') parser.add_argument('--family', default='systopic icons') args = parser.parse_args() for path in (args.set_file, args.font, args.css): if not os.path.isfile(path): fail('not found: ' + path) with open(args.set_file, encoding='utf-8') as handle: config = json.load(handle) icons = config.get('icons') or {} if not icons: fail('%s lists no icons' % os.path.basename(args.set_file)) known = codepoints(args.css) unknown = sorted({fa for fa in icons.values() if fa not in known}) if unknown: fail('unknown font awesome name(s): ' + ', '.join(unknown)) points = {name: known[fa] for name, fa in icons.items()} # The favicon may be named either way: a semantic name of this set # ("tools"), or a font awesome name straight from the icon gallery # ("sync-alt"). It is drawn as an SVG path, so unlike the set it does not # have to be part of the subset - a name that is only used here is fine. favicon = config.get('favicon') or '' colors = [DEFAULT_COLOR] plate = None if isinstance(favicon, dict): # "color" for one, "colors" for a shape-by-shape icon (top to bottom) named = favicon.get('colors') or favicon.get('color') or DEFAULT_COLOR colors = [str(c) for c in named] if isinstance(named, list) else [str(named)] plate = plate_of(favicon) favicon = favicon.get('icon') or '' favicon_point = None if favicon: if favicon in icons: favicon_point = known[icons[favicon]] elif favicon in known: favicon_point = known[favicon] else: fail("favicon '%s' is neither a name from this set (%s) nor a font" ' awesome name' % (favicon, ', '.join(sorted(icons)))) try: from fontTools import subset from fontTools.ttLib import TTFont except ImportError: fail('fonttools is missing - pip install fonttools brotli') # recalcTimestamp would stamp the build time into head.modified, so two # runs over an unchanged icons.json would produce two different files font = TTFont(args.font, recalcTimestamp=False) cmap = font.getBestCmap() wanted = dict(points) if favicon_point is not None: wanted[favicon + ' (favicon)'] = favicon_point missing = sorted(name for name, point in wanted.items() if point not in cmap) if missing: fail('not in ' + os.path.basename(args.font) + ': ' + ', '.join(missing)) # drawn before subsetting: the outline is the same, and this keeps the # favicon independent of what the subsetter does to the glyph order svg = None if favicon_point is not None: duotone = args.duotone if args.duotone and os.path.isfile(args.duotone) else None svg, how = favicon_svg(font.getGlyphSet(), cmap[favicon_point], colors, duotone, favicon_point, plate) if svg is None: fail("favicon '%s' has no outline" % favicon) options = subset.Options() options.desubroutinize = True options.hinting = False options.layout_features = [] options.name_IDs = [] options.notdef_outline = True options.drop_tables += ['DSIG'] subsetter = subset.Subsetter(options=options) subsetter.populate(unicodes=sorted(set(points.values()))) subsetter.subset(font) font.flavor = 'woff2' buffer = io.BytesIO() font.save(buffer) woff2 = buffer.getvalue() css = stylesheet(args.family, base64.b64encode(woff2).decode('ascii'), points, os.path.basename(args.font)) if not os.path.isdir(args.out_dir): os.makedirs(args.out_dir) css_path = os.path.join(args.out_dir, 'icons.css') with open(css_path, 'w', encoding='utf-8', newline='\n') as handle: handle.write(css) report = '%d icons, %d bytes woff2 -> icons.css (%d bytes)' % ( len(points), len(woff2), os.path.getsize(css_path)) if svg is not None: svg_path = os.path.join(args.out_dir, 'favicon.svg') with open(svg_path, 'w', encoding='utf-8', newline='\n') as handle: handle.write(svg) drawn = '%s %s %s' % (favicon, how, '/'.join(colors)) if plate: drawn += ' auf %s, radius %g%%, inhalt %g%%' % ( plate['color'] or 'nichts', plate['radius'], plate['scale']) report += ', favicon.svg (%s, %d bytes)' % (drawn, os.path.getsize(svg_path)) print('icons: ' + report) if __name__ == '__main__': main()