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