1"""
2Complete implementation of the XDG Icon Spec Version 0.8
3http://standards.freedesktop.org/icon-theme-spec/
4"""
5
6import os, time
7import re
8
9from xdg.IniFile import IniFile, is_ascii
10from xdg.BaseDirectory import xdg_data_dirs
11from xdg.Exceptions import NoThemeError, debug
12
13import xdg.Config
14
15class IconTheme(IniFile):
16    "Class to parse and validate IconThemes"
17    def __init__(self):
18        IniFile.__init__(self)
19
20    def __repr__(self):
21        return self.name
22
23    def parse(self, file):
24        IniFile.parse(self, file, ["Icon Theme", "KDE Icon Theme"])
25        self.dir = os.path.dirname(file)
26        (nil, self.name) = os.path.split(self.dir)
27
28    def getDir(self):
29        return self.dir
30
31    # Standard Keys
32    def getName(self):
33        return self.get('Name', locale=True)
34    def getComment(self):
35        return self.get('Comment', locale=True)
36    def getInherits(self):
37        return self.get('Inherits', list=True)
38    def getDirectories(self):
39        return self.get('Directories', list=True)
40    def getHidden(self):
41        return self.get('Hidden', type="boolean")
42    def getExample(self):
43        return self.get('Example')
44
45    # Per Directory Keys
46    def getSize(self, directory):
47        return self.get('Size', type="integer", group=directory)
48    def getContext(self, directory):
49        return self.get('Context', group=directory)
50    def getType(self, directory):
51        value = self.get('Type', group=directory)
52        if value:
53            return value
54        else:
55            return "Threshold"
56    def getMaxSize(self, directory):
57        value = self.get('MaxSize', type="integer", group=directory)
58        if value or value == 0:
59            return value
60        else:
61            return self.getSize(directory)
62    def getMinSize(self, directory):
63        value = self.get('MinSize', type="integer", group=directory)
64        if value or value == 0:
65            return value
66        else:
67            return self.getSize(directory)
68    def getThreshold(self, directory):
69        value = self.get('Threshold', type="integer", group=directory)
70        if value or value == 0:
71            return value
72        else:
73            return 2
74
75    # validation stuff
76    def checkExtras(self):
77        # header
78        if self.defaultGroup == "KDE Icon Theme":
79            self.warnings.append('[KDE Icon Theme]-Header is deprecated')
80
81        # file extension
82        if self.fileExtension == ".theme":
83            pass
84        elif self.fileExtension == ".desktop":
85            self.warnings.append('.desktop fileExtension is deprecated')
86        else:
87            self.warnings.append('Unknown File extension')
88
89        # Check required keys
90        # Name
91        try:
92            self.name = self.content[self.defaultGroup]["Name"]
93        except KeyError:
94            self.errors.append("Key 'Name' is missing")
95
96        # Comment
97        try:
98            self.comment = self.content[self.defaultGroup]["Comment"]
99        except KeyError:
100            self.errors.append("Key 'Comment' is missing")
101
102        # Directories
103        try:
104            self.directories = self.content[self.defaultGroup]["Directories"]
105        except KeyError:
106            self.errors.append("Key 'Directories' is missing")
107
108    def checkGroup(self, group):
109        # check if group header is valid
110        if group == self.defaultGroup:
111            try:
112                self.name = self.content[group]["Name"]
113            except KeyError:
114                self.errors.append("Key 'Name' in Group '%s' is missing" % group)
115            try:
116                self.name = self.content[group]["Comment"]
117            except KeyError:
118                self.errors.append("Key 'Comment' in Group '%s' is missing" % group)
119        elif group in self.getDirectories():
120            try:
121                self.type = self.content[group]["Type"]
122            except KeyError:
123                self.type = "Threshold"
124            try:
125                self.name = self.content[group]["Size"]
126            except KeyError:
127                self.errors.append("Key 'Size' in Group '%s' is missing" % group)
128        elif not (re.match("^\[X-", group) and is_ascii(group)):
129            self.errors.append("Invalid Group name: %s" % group)
130
131    def checkKey(self, key, value, group):
132        # standard keys
133        if group == self.defaultGroup:
134            if re.match("^Name"+xdg.Locale.regex+"$", key):
135                pass
136            elif re.match("^Comment"+xdg.Locale.regex+"$", key):
137                pass
138            elif key == "Inherits":
139                self.checkValue(key, value, list=True)
140            elif key == "Directories":
141                self.checkValue(key, value, list=True)
142            elif key == "Hidden":
143                self.checkValue(key, value, type="boolean")
144            elif key == "Example":
145                self.checkValue(key, value)
146            elif re.match("^X-[a-zA-Z0-9-]+", key):
147                pass
148            else:
149                self.errors.append("Invalid key: %s" % key)
150        elif group in self.getDirectories():
151            if key == "Size":
152                self.checkValue(key, value, type="integer")
153            elif key == "Context":
154                self.checkValue(key, value)
155            elif key == "Type":
156                self.checkValue(key, value)
157                if value not in ["Fixed", "Scalable", "Threshold"]:
158                    self.errors.append("Key 'Type' must be one out of 'Fixed','Scalable','Threshold', but is %s" % value)
159            elif key == "MaxSize":
160                self.checkValue(key, value, type="integer")
161                if self.type != "Scalable":
162                    self.errors.append("Key 'MaxSize' give, but Type is %s" % self.type)
163            elif key == "MinSize":
164                self.checkValue(key, value, type="integer")
165                if self.type != "Scalable":
166                    self.errors.append("Key 'MinSize' give, but Type is %s" % self.type)
167            elif key == "Threshold":
168                self.checkValue(key, value, type="integer")
169                if self.type != "Threshold":
170                    self.errors.append("Key 'Threshold' give, but Type is %s" % self.type)
171            elif re.match("^X-[a-zA-Z0-9-]+", key):
172                pass
173            else:
174                self.errors.append("Invalid key: %s" % key)
175
176
177class IconData(IniFile):
178    "Class to parse and validate IconData Files"
179    def __init__(self):
180        IniFile.__init__(self)
181
182    def __repr__(self):
183        displayname = self.getDisplayName()
184        if displayname:
185            return "<IconData: %s>" % displayname
186        else:
187            return "<IconData>"
188
189    def parse(self, file):
190        IniFile.parse(self, file, ["Icon Data"])
191
192    # Standard Keys
193    def getDisplayName(self):
194        """Retrieve the display name from the icon data, if one is specified."""
195        return self.get('DisplayName', locale=True)
196    def getEmbeddedTextRectangle(self):
197        """Retrieve the embedded text rectangle from the icon data as a list of
198        numbers (x0, y0, x1, y1), if it is specified."""
199        return self.get('EmbeddedTextRectangle', type="integer", list=True)
200    def getAttachPoints(self):
201        """Retrieve the anchor points for overlays & emblems from the icon data,
202        as a list of co-ordinate pairs, if they are specified."""
203        return self.get('AttachPoints', type="point", list=True)
204
205    # validation stuff
206    def checkExtras(self):
207        # file extension
208        if self.fileExtension != ".icon":
209            self.warnings.append('Unknown File extension')
210
211    def checkGroup(self, group):
212        # check if group header is valid
213        if not (group == self.defaultGroup \
214        or (re.match("^\[X-", group) and is_ascii(group))):
215            self.errors.append("Invalid Group name: %s" % group.encode("ascii", "replace"))
216
217    def checkKey(self, key, value, group):
218        # standard keys
219        if re.match("^DisplayName"+xdg.Locale.regex+"$", key):
220            pass
221        elif key == "EmbeddedTextRectangle":
222            self.checkValue(key, value, type="integer", list=True)
223        elif key == "AttachPoints":
224            self.checkValue(key, value, type="point", list=True)
225        elif re.match("^X-[a-zA-Z0-9-]+", key):
226            pass
227        else:
228            self.errors.append("Invalid key: %s" % key)
229
230
231
232icondirs = []
233for basedir in xdg_data_dirs:
234    icondirs.append(os.path.join(basedir, "icons"))
235    icondirs.append(os.path.join(basedir, "pixmaps"))
236icondirs.append(os.path.expanduser("~/.icons"))
237
238# just cache variables, they give a 10x speed improvement
239themes = []
240theme_cache = {}
241dir_cache = {}
242icon_cache = {}
243
244def getIconPath(iconname, size = None, theme = None, extensions = ["png", "svg", "xpm"]):
245    """Get the path to a specified icon.
246
247    size :
248      Icon size in pixels. Defaults to ``xdg.Config.icon_size``.
249    theme :
250      Icon theme name. Defaults to ``xdg.Config.icon_theme``. If the icon isn't
251      found in the specified theme, it will be looked up in the basic 'hicolor'
252      theme.
253    extensions :
254      List of preferred file extensions.
255
256    Example::
257
258        >>> getIconPath("inkscape", 32)
259        '/usr/share/icons/hicolor/32x32/apps/inkscape.png'
260    """
261
262    global themes
263
264    if size == None:
265        size = xdg.Config.icon_size
266    if theme == None:
267        theme = xdg.Config.icon_theme
268
269    # if we have an absolute path, just return it
270    if os.path.isabs(iconname):
271        return iconname
272
273    # check if it has an extension and strip it
274    if os.path.splitext(iconname)[1][1:] in extensions:
275        iconname = os.path.splitext(iconname)[0]
276
277    # parse theme files
278    if (themes == []) or (themes[0].name != theme):
279        themes = list(__get_themes(theme))
280
281    # more caching (icon looked up in the last 5 seconds?)
282    tmp = (iconname, size, theme, tuple(extensions))
283    try:
284        timestamp, icon = icon_cache[tmp]
285    except KeyError:
286        pass
287    else:
288        if (time.time() - timestamp) >= xdg.Config.cache_time:
289            del icon_cache[tmp]
290        else:
291            return icon
292
293    for thme in themes:
294        icon = LookupIcon(iconname, size, thme, extensions)
295        if icon:
296            icon_cache[tmp] = (time.time(), icon)
297            return icon
298
299    # cache stuff again (directories looked up in the last 5 seconds?)
300    for directory in icondirs:
301        if (directory not in dir_cache \
302            or (int(time.time() - dir_cache[directory][1]) >= xdg.Config.cache_time \
303            and dir_cache[directory][2] < os.path.getmtime(directory))) \
304            and os.path.isdir(directory):
305            dir_cache[directory] = (os.listdir(directory), time.time(), os.path.getmtime(directory))
306
307    for dir, values in dir_cache.items():
308        for extension in extensions:
309            try:
310                if iconname + "." + extension in values[0]:
311                    icon = os.path.join(dir, iconname + "." + extension)
312                    icon_cache[tmp] = [time.time(), icon]
313                    return icon
314            except UnicodeDecodeError as e:
315                if debug:
316                    raise e
317                else:
318                    pass
319
320    # we haven't found anything? "hicolor" is our fallback
321    if theme != "hicolor":
322        icon = getIconPath(iconname, size, "hicolor")
323        icon_cache[tmp] = [time.time(), icon]
324        return icon
325
326def getIconData(path):
327    """Retrieve the data from the .icon file corresponding to the given file. If
328    there is no .icon file, it returns None.
329
330    Example::
331
332        getIconData("/usr/share/icons/Tango/scalable/places/folder.svg")
333    """
334    if os.path.isfile(path):
335        icon_file = os.path.splitext(path)[0] + ".icon"
336        if os.path.isfile(icon_file):
337            data = IconData()
338            data.parse(icon_file)
339            return data
340
341def __get_themes(themename):
342    """Generator yielding IconTheme objects for a specified theme and any themes
343    from which it inherits.
344    """
345    for dir in icondirs:
346        theme_file = os.path.join(dir, themename, "index.theme")
347        if os.path.isfile(theme_file):
348            break
349        theme_file = os.path.join(dir, themename, "index.desktop")
350        if os.path.isfile(theme_file):
351            break
352    else:
353        if debug:
354            raise NoThemeError(themename)
355        return
356
357    theme = IconTheme()
358    theme.parse(theme_file)
359    yield theme
360    for subtheme in theme.getInherits():
361        for t in __get_themes(subtheme):
362            yield t
363
364def LookupIcon(iconname, size, theme, extensions):
365    # look for the cache
366    if theme.name not in theme_cache:
367        theme_cache[theme.name] = []
368        theme_cache[theme.name].append(time.time() - (xdg.Config.cache_time + 1)) # [0] last time of lookup
369        theme_cache[theme.name].append(0)               # [1] mtime
370        theme_cache[theme.name].append(dict())          # [2] dir: [subdir, [items]]
371
372    # cache stuff (directory lookuped up the in the last 5 seconds?)
373    if int(time.time() - theme_cache[theme.name][0]) >= xdg.Config.cache_time:
374        theme_cache[theme.name][0] = time.time()
375        for subdir in theme.getDirectories():
376            for directory in icondirs:
377                dir = os.path.join(directory,theme.name,subdir)
378                if (dir not in theme_cache[theme.name][2] \
379                or theme_cache[theme.name][1] < os.path.getmtime(os.path.join(directory,theme.name))) \
380                and subdir != "" \
381                and os.path.isdir(dir):
382                    theme_cache[theme.name][2][dir] = [subdir, os.listdir(dir)]
383                    theme_cache[theme.name][1] = os.path.getmtime(os.path.join(directory,theme.name))
384
385    for dir, values in theme_cache[theme.name][2].items():
386        if DirectoryMatchesSize(values[0], size, theme):
387            for extension in extensions:
388                if iconname + "." + extension in values[1]:
389                    return os.path.join(dir, iconname + "." + extension)
390
391    minimal_size = 2**31
392    closest_filename = ""
393    for dir, values in theme_cache[theme.name][2].items():
394        distance = DirectorySizeDistance(values[0], size, theme)
395        if distance < minimal_size:
396            for extension in extensions:
397                if iconname + "." + extension in values[1]:
398                    closest_filename = os.path.join(dir, iconname + "." + extension)
399                    minimal_size = distance
400
401    return closest_filename
402
403def DirectoryMatchesSize(subdir, iconsize, theme):
404    Type = theme.getType(subdir)
405    Size = theme.getSize(subdir)
406    Threshold = theme.getThreshold(subdir)
407    MinSize = theme.getMinSize(subdir)
408    MaxSize = theme.getMaxSize(subdir)
409    if Type == "Fixed":
410        return Size == iconsize
411    elif Type == "Scaleable":
412        return MinSize <= iconsize <= MaxSize
413    elif Type == "Threshold":
414        return Size - Threshold <= iconsize <= Size + Threshold
415
416def DirectorySizeDistance(subdir, iconsize, theme):
417    Type = theme.getType(subdir)
418    Size = theme.getSize(subdir)
419    Threshold = theme.getThreshold(subdir)
420    MinSize = theme.getMinSize(subdir)
421    MaxSize = theme.getMaxSize(subdir)
422    if Type == "Fixed":
423        return abs(Size - iconsize)
424    elif Type == "Scalable":
425        if iconsize < MinSize:
426            return MinSize - iconsize
427        elif iconsize > MaxSize:
428            return MaxSize - iconsize
429        return 0
430    elif Type == "Threshold":
431        if iconsize < Size - Threshold:
432            return MinSize - iconsize
433        elif iconsize > Size + Threshold:
434            return iconsize - MaxSize
435        return 0
436