1#!/usr/bin/env python3 2# Extracts symbolic icons from a contact sheet using Inkscape. 3# Copyright (c) 2013 Andrew Chadwick <a.t.chadwick@gmail.com> 4# Copyright (c) 2020 The MyPaint Team 5# 6# Originally based on Jakub Steiner's r.rb, 7# rewritten in Python for the MyPaint distrib. 8# Jakub Steiner <jimmac@gmail.com> 9 10# Depends on python-scour 11 12## Imports 13from __future__ import division, print_function 14 15import argparse 16from copy import deepcopy 17import gzip 18import logging 19import os 20import sys 21import xml.etree.ElementTree as ET 22 23from scour.scour import start as scour_optimize 24 25logger = logging.getLogger(__file__) 26 27# Constants 28 29ICON_SHEET = "symbolic-icons.svgz" 30OUTPUT_ICONS_ROOT = "../desktop/icons" 31OUTPUT_THEME = "hicolor" 32XLINK = "http://www.w3.org/1999/xlink" 33SVG = "http://www.w3.org/2000/svg" 34NAMESPACES = { 35 "inkscape": "http://www.inkscape.org/namespaces/inkscape", 36 "svg": SVG, 37 "xlink": XLINK, 38} 39SUFFIX24 = ":24" 40 41 42# Utilities 43 44class FakeFile: 45 """ String wrapper providing a subset of the file interface 46 Used for the call to scour's optimizer for both input and output. 47 """ 48 49 def __init__(self, string): 50 self.string = string 51 52 def write(self, newstring): 53 self.string = newstring 54 55 def read(self, ): 56 return self.string 57 58 def close(self): 59 pass 60 61 def name(self): 62 return "" 63 64 65class FakeOptions: 66 """ Stand-in for values normally returned from an OptionParser 67 Used for the call to scour's optimizer 68 """ 69 70 def __init__(self, **kwargs): 71 for k, v in kwargs.items(): 72 setattr(self, k, v) 73 74 75def get_by_attrib(parent, pred): 76 """Utility function for retrieveing child elements by attribute predicates 77 """ 78 return [c for c in parent.getchildren() if pred(c.attrib)] 79 80 81def by_attrib(parent, attr, attr_pred): 82 """Retrieves the elements matching an attribute predicate 83 The predicate is only evaluated if the attribute is present. 84 """ 85 def pred(attribs): 86 if attr in attribs: 87 return attr_pred(attribs[attr]) 88 else: 89 return False 90 return get_by_attrib(parent, pred) 91 92 93# This reference resolution only handles simple cases 94# and does not produce optimal (or even correct) results 95# in the general case, but is sufficient for the current icons. 96def resolve_references(refsvg, base): 97 """Resolve external references in ``base`` 98 Any <use> tag in base will be replaced with the element it refers 99 to unless that element is already a part of base. Assumes valid svg. 100 """ 101 def deref(parent, child, index): 102 if child.tag.endswith('use'): 103 refid = gethref(child) 104 el = base.find('.//*[@id="%s"]' % refid) 105 if el is None: # Need to fetch the reference 106 ob = deepcopy(refsvg.find('.//*[@id="%s"]' % refid)) 107 assert ob is not None 108 transform = child.get('transform') 109 if transform: 110 ob.set('transform', transform) 111 parent.remove(child) 112 parent.insert(index, ob) 113 deref(parent, ob, index) # Recurse in case of use -> use 114 else: 115 for i, c in enumerate(child.getchildren()): 116 deref(child, c, i) 117 118 for i, c in enumerate(base.getchildren()): 119 deref(base, c, i) 120 121 122def extract_icon(svg, icon_elem, output_dir, output_svg): 123 """Extract one icon""" 124 group_id = icon_elem.attrib['id'] 125 logger.info("Extracting %s", group_id) 126 if not os.path.exists(output_dir): 127 os.makedirs(output_dir) 128 if group_id.endswith(SUFFIX24): 129 size = 24 130 icon_name = group_id[:-3] + "-symbolic" 131 else: 132 size = 16 133 icon_name = group_id + "-symbolic" 134 # Remove the backdrop (should always be the 135 # first element, but we don't rely on it). 136 backdrop, = by_attrib( 137 icon_elem, '{%s}href' % XLINK, 138 lambda s: s.startswith('#icon-base')) 139 icon_elem.remove(backdrop) 140 141 # Resolve references to objects outside of the icon 142 resolve_references(svg, icon_elem) 143 144 # Remove the `transform` attribute 145 root = output_svg.getroot() 146 icon_elem.attrib.pop('transform') 147 for c in icon_elem.getchildren(): 148 root.append(c) 149 root.set('width', str(size)) 150 root.set('height', str(size)) 151 152 # Remove unused style elements 153 clean_styles(root) 154 svgstr = FakeFile(ET.tostring(output_svg.getroot(), encoding="utf-8")) 155 options = FakeOptions( 156 newlines=False, shorten_ids=True, digits=5, 157 strip_ids=True, remove_descriptive_elements=True, 158 indent_depth=0, quiet=True 159 ) 160 scour_optimize(options, svgstr, svgstr) 161 output_file = os.path.join(output_dir, "%s.svg" % (icon_name,)) 162 with open(output_file, 'wb') as f: 163 f.write(svgstr.read()) 164 165 166def parse_style(string): 167 """Given a well-formed style string, return the corresponding dict""" 168 return {k: v for k, v in 169 (p.split(':') for p in string.split(';') if ':' in p)} 170 171 172def serialize_style(style_dict): 173 """Serialize a dict to a well-formed style string""" 174 return ";".join([k + ":" + v for k, v in style_dict.items()]) 175 176 177def clean_styles(elem): 178 """Recursively remove useless style attributes 179 Also moves common fill declarations to the topmost element. 180 """ 181 fill_cols = clean_style(elem) 182 for child in elem: 183 fill_cols = fill_cols.union(clean_styles(child)) 184 # Consolidate fill color (move it to the least common denominator) 185 if len(fill_cols) == 1: # Generally this should be the case 186 for c in elem: 187 # Exclude circles from the 'fill' attribute removal, 188 # since gtk does handle the rendering correctly without it. 189 if 'circle' not in c.tag and 'style' in c.attrib: 190 style = parse_style(c.attrib['style']) 191 style.pop('fill', None) 192 if not style: 193 c.attrib.pop('style') 194 else: 195 c.attrib['style'] = serialize_style(style) 196 style = parse_style(elem.attrib.get('style', '')) 197 style['fill'] = list(fill_cols)[0] 198 elem.attrib['style'] = serialize_style(style) 199 return fill_cols 200 201 202def gethref(icon): 203 return icon.attrib['{%s}href' % XLINK][1:] 204 205 206def clean_style(elem): 207 """Remove unused style attributes based on a fixed list 208 A style attribute is removed if: 209 1. it is not in the list 210 2. it is in the list, but with a default value 211 Expand the list as is necessary. 212 """ 213 # Remove unused stuff from <use> elements - split out 214 if elem.tag.endswith('use'): 215 keys = list(elem.attrib.keys()) 216 for k in keys: 217 if k in {'width', 'height', 'x', 'y'}: 218 elem.attrib.pop(k) 219 # At the point this is run, it is assumed that all strokes 220 # have been converted to paths, hence the only values we 221 # care about are opacity and fill. This may change in the 222 # future, hence the more general implementation. 223 key = 'style' 224 useful = ["opacity", "fill"] 225 defaults = {"opacity": (float, 1.0)} 226 227 def is_default(k, v): 228 if k in defaults: 229 conv, default = defaults[k] 230 return conv(v) == default 231 return False 232 styles = None 233 if key in elem.attrib: 234 styles = parse_style(elem.attrib[key]) 235 cleaned = {k: v for k, v in styles.items() 236 if k in useful and not is_default(k, v)} 237 if cleaned: 238 elem.attrib[key] = serialize_style(cleaned) 239 else: 240 elem.attrib.pop(key) 241 # Return the fill color in a singleton (or empty) set 242 return {v for k, v in (styles or dict()).items() if k == 'fill'} 243 244 245def is_icon(e): 246 valid_tag = e.tag in {s % SVG for s in ('{%s}g', '{%s}use')} 247 return valid_tag and e.get('id') and e.get('id').startswith('mypaint-') 248 249 250def get_icon_layer(svg): 251 return svg.find('svg:g[@id="icons"]', NAMESPACES) 252 253 254def extract_icons(svg, basedir, *ids): 255 """Extract icon groups using Inkscape, both 16px scalable & 24x24""" 256 257 # Make a copy of the tree 258 base = deepcopy(svg) 259 baseroot = base.getroot() 260 # Empty the copy - it will act as the base for each extracted icon 261 for c in baseroot.getchildren(): 262 baseroot.remove(c) 263 264 icon_layer = get_icon_layer(svg) 265 icons = (i for i in icon_layer if is_icon(i)) 266 if ids: 267 icons = (i for i in icons if i.get('id') in ids) 268 num_extracted = 0 269 for icon in icons: 270 num_extracted += 1 271 iconid = icon.get('id') 272 if icon.tag.endswith('use'): 273 icon = deepcopy(icon_layer.find('*[@id="%s"]' % gethref(icon))) 274 icon.attrib['id'] = iconid 275 typedir = "24x24" if iconid.endswith(SUFFIX24) else "scalable" 276 outdir = os.path.join(basedir, typedir, "actions") 277 extract_icon(svg, deepcopy(icon), outdir, deepcopy(base)) 278 logger.info("Finished extracting %d icons" % num_extracted) 279 280 281def get_icon_ids(svg): 282 """Returns the ids of elements marked as being icons""" 283 return (e.get('id') for e in get_icon_layer(svg) if is_icon(e)) 284 285 286def invalid_ids(svg, ids): 287 return ids.difference(ids.intersection(set(get_icon_ids(svg)))) 288 289 290def main(options): 291 """Main function for the tool""" 292 logging.basicConfig(level=logging.INFO) 293 for prefix, uri in NAMESPACES.items(): 294 ET.register_namespace(prefix, uri) 295 basedir = os.path.join(OUTPUT_ICONS_ROOT, OUTPUT_THEME) 296 if not os.path.isdir(basedir): 297 logger.error("No dir named %r", basedir) 298 sys.exit(1) 299 logger.info("Reading %r", ICON_SHEET) 300 with gzip.open(ICON_SHEET, mode='rb') as svg_fp: 301 svg = ET.parse(svg_fp) 302 # List all icon ids in sheet 303 if options.list_ids: 304 for icon_id in get_icon_ids(svg): 305 print(icon_id) 306 # Extract all icons 307 elif options.extract_all: 308 extract_icons(svg, basedir) 309 # Extract icons by ids 310 else: 311 invalid = invalid_ids(svg, set(options.extract)) 312 if invalid: 313 logger.error("Icon ids not found in icon sheet:\n{ids}".format( 314 ids="\n".join(sorted(invalid)) 315 )) 316 logger.error("Extraction cancelled!") 317 sys.exit(1) 318 else: 319 extract_icons(svg, basedir, *options.extract) 320 321 322if __name__ == '__main__': 323 argparser = argparse.ArgumentParser() 324 group = argparser.add_mutually_exclusive_group(required=True) 325 group.add_argument("--list", action="store_true", dest="list_ids", 326 help="Print all icon ids to stdout and quit.") 327 group.add_argument("--extract-all", action="store_true", 328 help="Extract all icons in the icon sheet.") 329 group.add_argument("--extract", type=str, nargs="+", metavar="ICON_ID", 330 help="Extract the icons with the given ids.") 331 options = argparser.parse_args() 332 main(options) 333