source: appstream-generator/src/asgen/handlers/iconhandler.d @ 4841

Last change on this file since 4841 was 4841, checked in by Juanma, 2 years ago

Initial release

File size: 24.1 KB
Line 
1/*
2 * Copyright (C) 2016 Matthias Klumpp <matthias@tenstral.net>
3 *
4 * Licensed under the GNU Lesser General Public License Version 3
5 *
6 * This program is free software: you can redistribute it and/or modify
7 * it under the terms of the GNU Lesser General Public License as published by
8 * the Free Software Foundation, either version 3 of the license, or
9 * (at your option) any later version.
10 *
11 * This software is distributed in the hope that it will be useful,
12 * but WITHOUT ANY WARRANTY; without even the implied warranty of
13 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14 * GNU Lesser General Public License for more details.
15 *
16 * You should have received a copy of the GNU Lesser General Public License
17 * along with this software.  If not, see <http://www.gnu.org/licenses/>.
18 */
19
20module asgen.handlers.iconhandler;
21
22import std.stdio;
23import std.string;
24import std.array : replace;
25import std.path : baseName, buildPath;
26import std.uni : toLower;
27import std.file : mkdirRecurse;
28import std.algorithm : canFind;
29import std.variant;
30import std.parallelism;
31import std.typecons : scoped;
32import std.concurrency : Generator, yield;
33import glib.KeyFile;
34import appstream.Component;
35import appstream.Icon;
36static import std.file;
37
38import asgen.utils;
39import asgen.logging;
40import asgen.result;
41import asgen.image;
42import asgen.backends.interfaces;
43import asgen.contentsstore;
44static import asgen.config;
45
46
47// all image extensions that we recognize as possible for icons.
48// the most favorable file extension needs to come first to prefer it
49private immutable possibleIconExts = [".png", ".jpg", ".svgz", ".svg", ".gif", ".ico", ".xpm"];
50
51// the image extensions that we will actually allow software to have.
52private immutable allowedIconExts  = [".png", ".jpg", ".svgz", ".svg", ".xpm"];
53
54public immutable wantedIconSizes  = [ImageSize (64), ImageSize (128)];
55
56/**
57 * Describes an icon theme as specified in the XDG theme spec.
58 */
59private final class Theme
60{
61
62private:
63    string name;
64    Algebraic!(int, string)[string][] directories;
65
66public:
67
68    this (string name, const(ubyte)[] indexData)
69    {
70        this.name = name;
71
72        auto index = new KeyFile ();
73        auto indexText = cast(string) indexData;
74        index.loadFromData (indexText, -1, GKeyFileFlags.NONE);
75
76        size_t dummy;
77        foreach (section; index.getGroups (dummy)) {
78            string type;
79            string context;
80            int threshold;
81            int size;
82            int minSize;
83            int maxSize;
84            try {
85                size = index.getInteger (section, "Size");
86                context = index.getString (section, "Context");
87            } catch { continue; }
88
89            try {
90                threshold = index.getInteger (section, "Threshold");
91            } catch {
92                threshold = 2;
93            }
94            try {
95                type = index.getString (section, "Type");
96            } catch {
97                type = "Threshold";
98            }
99            try {
100                minSize = index.getInteger (section, "MinSize");
101            } catch {
102                minSize = size;
103            }
104            try {
105                maxSize = index.getInteger (section, "MaxSize");
106            } catch {
107                maxSize = size;
108            }
109
110            if (size == 0)
111                continue;
112            auto themedir = [
113                "path": Algebraic!(int, string) (section),
114                "type": Algebraic!(int, string) (type),
115                "size": Algebraic!(int, string) (size),
116                "minsize": Algebraic!(int, string) (minSize),
117                "maxsize": Algebraic!(int, string) (maxSize),
118                "threshold": Algebraic!(int, string) (threshold)
119            ];
120            directories ~= themedir;
121        }
122    }
123
124    this (string name, Package pkg)
125    {
126        auto indexData = pkg.getFileData (buildPath ("/usr/share/icons", name, "index.theme"));
127        this (name, indexData);
128    }
129
130    private bool directoryMatchesSize (Algebraic!(int, string)[string] themedir, ImageSize size)
131    {
132        string type = themedir["type"].get!(string);
133        if (type == "Fixed")
134            return size.toInt () == themedir["size"].get!(int);
135        if (type == "Scalable") {
136            if ((themedir["minsize"].get!(int) <= size.toInt ()) && (size.toInt () <= themedir["maxsize"].get!(int)))
137                return true;
138            return false;
139        }
140        if (type == "Threshold") {
141            auto themeSize = themedir["size"].get!(int);
142            auto th = themedir["threshold"].get!(int);
143            if (((themeSize - th) <= size.toInt ()) && (size.toInt () <= (themeSize + th)))
144                return true;
145            return false;
146        }
147
148        return false;
149    }
150
151    /**
152     * Returns an iteratable of possible icon filenames that match 'name' and 'size'.
153     **/
154    auto matchingIconFilenames (string iname, ImageSize size)
155    {
156        auto gen = new Generator!string (
157        {
158            foreach (themedir; this.directories) {
159                if (directoryMatchesSize (themedir, size)) {
160                    // best filetype needs to come first to be preferred, only types allowed by the spec are handled at all
161                    foreach (extension; ["png", "svgz", "svg", "xpm"])
162                        yield (format ("/usr/share/icons/%s/%s/%s.%s", this.name, themedir["path"].get!(string), iname, extension));
163                }
164            }
165        });
166
167        return gen;
168    }
169}
170
171
172/**
173 * Finds icons in a software archive and stores them in the
174 * correct sizes for a given AppStream component.
175 */
176final class IconHandler
177{
178
179private:
180    string mediaExportPath;
181
182    Theme[] themes;
183    Package[string] iconFiles;
184    string[] themeNames;
185
186public:
187
188    this (string mediaPath, Package[string] pkgMap, string iconTheme = null)
189    {
190        logDebug ("Creating new IconHandler");
191
192        mediaExportPath = mediaPath;
193
194        // Preseeded theme names.
195        // * prioritize hicolor, because that's where apps often install their upstream icon
196        // * then look at the theme given in the config file
197        // * allow Breeze icon theme, needed to support KDE apps (they have no icon at all, otherwise...)
198        // * in rare events, GNOME needs the same treatment, so special-case Adwaita as well
199        // * We need at least one icon theme to provide the default XDG icon spec stock icons.
200        //   A fair take would be to select them between KDE and GNOME at random, but for consistency and
201        //   because everyone hates unpredictable behavior, we sort alphabetically and prefer Adwaita over Breeze.
202        themeNames = ["hicolor"];
203        if (iconTheme !is null)
204            themeNames ~= iconTheme;
205        themeNames ~= "Adwaita";
206        themeNames ~= "breeze";
207
208        Package getPackage (string pkid)
209        {
210            if (pkid is null)
211                return null;
212            auto pkgP = (pkid in pkgMap);
213            if (pkgP is null)
214                return null;
215            else
216                return *pkgP;
217        }
218
219        // open package contents cache
220        auto ccache = scoped!ContentsStore ();
221        ccache.open (asgen.config.Config.get ());
222
223        // load data from the contents index.
224        // we don't show mercy to memory here, we just want the icon lookup to be fast,
225        // so we have to cache the data.
226        Theme[string] tmpThemes;
227        auto filesPkids = ccache.getIconFilesMap (pkgMap.keys ());
228        foreach (fname; parallel (filesPkids.byKey (), 100)) {
229            if (fname.startsWith ("/usr/share/pixmaps/")) {
230                auto pkg = getPackage (filesPkids[fname]);
231                if (pkg is null)
232                    continue;
233                synchronized (this) iconFiles[fname] = pkg;
234                continue;
235            }
236
237            // optimization: check if we actually have an interesting path before
238            // entering the foreach loop.
239            if (!fname.startsWith ("/usr/share/icons/"))
240                continue;
241
242            auto pkg = getPackage (filesPkids[fname]);
243            if (pkg is null)
244                continue;
245
246            foreach (name; themeNames) {
247                if (fname == format ("/usr/share/icons/%s/index.theme", name)) {
248                    synchronized (this) tmpThemes[name] = new Theme (name, pkg);
249                } else if (fname.startsWith (format ("/usr/share/icons/%s", name))) {
250                    synchronized (this) iconFiles[fname] = pkg;
251                }
252            }
253        }
254
255        // when running on partial repos (e.g. PPAs) we might not have a package containing the
256        // hicolor theme definition. Since we always need it to be there to properly process icons,
257        // we inject our own copy here.
258        if ("hicolor" !in tmpThemes) {
259            logInfo ("No packaged hicolor icon theme found, using built-in one.");
260            auto hicolorThemeIndex = getDataPath ("hicolor-theme-index.theme");
261            if (!std.file.exists (hicolorThemeIndex)) {
262                logError ("Hicolor icon theme index at '%s' was not found! We will not be able to handle icons in this theme.", hicolorThemeIndex);
263            } else {
264                ubyte[] indexData;
265                auto f = File (hicolorThemeIndex, "r");
266                while (!f.eof) {
267                    char[GENERIC_BUFFER_SIZE] buf;
268                    indexData ~= f.rawRead (buf);
269                }
270
271                tmpThemes["hicolor"] = new Theme ("hicolor", indexData);
272            }
273        }
274
275        // this is necessary to keep the ordering (and therefore priority) of themes.
276        // we don't know the order in which we find index.theme files in the code above,
277        // therefore this sorting is necessary.
278        foreach (tname; themeNames) {
279            if (tname in tmpThemes)
280                themes ~= tmpThemes[tname];
281        }
282
283        logDebug ("Created new IconHandler.");
284    }
285
286    private string getIconNameAndClear (Component cpt)
287    {
288        string name = null;
289
290        // a not-processed icon name is stored as "1x1px" icon, so we can
291        // quickly identify it here.
292        auto icon = componentGetStockIcon (cpt);
293        if (!icon.isNull)
294            name = icon.get.getName ();
295
296        // clear the list of icons in this component
297        auto iconsArray = cpt.getIcons ();
298        if (iconsArray.len > 0)
299            iconsArray.removeRange (0, iconsArray.len);
300        return name;
301    }
302
303    static private bool iconAllowed (string iconName)
304    {
305        foreach (ref ext; allowedIconExts)
306            if (iconName.endsWith (ext))
307                return true;
308        return false;
309    }
310
311    private ImageFormat imageKindFromFile (string fname)
312    {
313        if (fname.endsWith (".png"))
314            return ImageFormat.PNG;
315        if ((fname.endsWith (".jpg")) || (fname.endsWith (".jpeg")))
316            return ImageFormat.JPEG;
317        if (fname.endsWith (".svg"))
318            return ImageFormat.SVG;
319        if (fname.endsWith (".svgz"))
320            return ImageFormat.SVGZ;
321        if (fname.endsWith (".xpm"))
322            return ImageFormat.XPM;
323        return ImageFormat.UNKNOWN;
324    }
325
326    /**
327     * Generates potential filenames of the icon that is searched for in the
328     * given size.
329     **/
330    private auto possibleIconFilenames (string iconName, ImageSize size)
331    {
332        auto gen = new Generator!string (
333        {
334            foreach (theme; this.themes) {
335                foreach (fname; theme.matchingIconFilenames (iconName, size))
336                    yield (fname);
337            }
338
339            // check pixmaps for icons
340            foreach (extension; possibleIconExts)
341                yield (format ("/usr/share/pixmaps/%s%s", iconName, extension));
342        });
343
344        return gen;
345    }
346
347    /**
348     * Helper structure for the findIcons
349     * method.
350     **/
351    private struct IconFindResult
352    {
353        Package pkg;
354        string fname;
355
356        this (Package pkg, string fname) {
357            this.pkg = pkg;
358            this.fname = fname;
359        }
360    }
361
362    /**
363     * Looks up 'icon' with 'size' in popular icon themes according to the XDG
364     * icon theme spec.
365     **/
366    auto findIcons (string iconName, const ImageSize[] sizes, Package pkg = null)
367    {
368        IconFindResult[ImageSize] sizeMap = null;
369
370        foreach (size; sizes) {
371            foreach (fname; possibleIconFilenames (iconName, size)) {
372                if (pkg !is null) {
373                    // we are supposed to search in one particular package
374                    if (pkg.contents.canFind (fname)) {
375                        sizeMap[size] = IconFindResult (pkg, fname);
376                        break;
377                    }
378                } else {
379                    // global search in all packages
380                    auto pkgP = (fname in iconFiles);
381                    // continue if filename is not in map
382                    if (pkgP is null)
383                        continue;
384                    sizeMap[size] = IconFindResult (*pkgP, fname);
385                    break;
386                }
387            }
388        }
389
390        return sizeMap;
391    }
392
393    /**
394     * Strip file extension from icon.
395     */
396    string stripIconExt (ref string iconName)
397    {
398        if (iconName.endsWith (".png"))
399            return iconName[0..$-4];
400        if (iconName.endsWith (".svg"))
401            return iconName[0..$-4];
402        if (iconName.endsWith (".xpm"))
403            return iconName[0..$-4];
404        if (iconName.endsWith (".svgz"))
405            return iconName[0..$-5];
406        return iconName;
407    }
408
409    /**
410     * Extracts the icon from the package and stores it in the cache.
411     * Ensures the stored icon always has the size given in "size", and renders
412     * scalable vectorgraphics if necessary.
413     *
414     * Params:
415     *      cpt           = The component this icon belongs to.
416     *      res           = The result the component belongs to.
417     *      cptExportPath = The data export directory of the component.
418     *      sourcePkg     = The package the to-be-extracted icon is located in.
419     *      iconPath      = The (absolute) path to the icon.
420     *      size          = The size the icon should be stored in.
421     **/
422    private bool storeIcon (Component cpt,
423                            GeneratorResult gres,
424                            string cptExportPath,
425                            Package sourcePkg,
426                            string iconPath,
427                            ImageSize size)
428    {
429        auto iformat = imageKindFromFile (iconPath);
430        if (iformat == ImageFormat.UNKNOWN) {
431            gres.addHint (cpt.getId (), "icon-format-unsupported", ["icon_fname": baseName (iconPath)]);
432            return false;
433        }
434
435        auto path = buildPath (cptExportPath, "icons", size.toString ());
436        auto iconName = format ("%s_%s", gres.pkgname,  baseName (iconPath));
437
438        if (iconName.endsWith (".svgz"))
439            iconName = iconName.replace (".svgz", ".png");
440        else if (iconName.endsWith (".svg"))
441            iconName = iconName.replace (".svg", ".png");
442        else if (iconName.endsWith (".xpm"))
443            iconName = iconName.replace (".xpm", ".png");
444        auto iconStoreLocation = buildPath (path, iconName);
445
446        if (std.file.exists (iconStoreLocation)) {
447            // we already extracted that icon, skip the extraction step
448            // and just add the new icon.
449            auto icon = new Icon ();
450            icon.setKind (IconKind.CACHED);
451            icon.setWidth (size.width);
452            icon.setHeight (size.height);
453            icon.setName (iconName);
454            cpt.addIcon (icon);
455            return true;
456        }
457
458        // filepath is checked because icon can reside in another binary
459        // eg amarok's icon is in amarok-data
460        ubyte[] iconData = null;
461        try {
462            iconData = cast(ubyte[]) sourcePkg.getFileData (iconPath);
463        } catch (Exception e) {
464            gres.addHint(cpt.getId (), "pkg-extract-error", ["fname": baseName (iconPath), "pkg_fname": baseName (sourcePkg.filename), "error": e.msg]);
465            return false;
466        }
467
468        if (iconData.empty ()) {
469            gres.addHint (cpt.getId (), "pkg-empty-file", ["fname": baseName (iconPath), "pkg_fname": baseName (sourcePkg.filename)]);
470            return false;
471        }
472
473        if ((iformat == ImageFormat.SVG) || (iformat == ImageFormat.SVGZ)) {
474            // create target directory
475            mkdirRecurse (path);
476
477            try {
478                auto cv = new Canvas (size.width, size.height);
479                cv.renderSvg (iconData);
480                cv.savePng (iconStoreLocation);
481                delete cv;
482            } catch (Exception e) {
483                gres.addHint(cpt.getId (), "image-write-error", ["fname": baseName (iconPath), "pkg_fname": baseName (sourcePkg.filename), "error": e.msg]);
484                return false;
485            }
486        } else {
487            Image img;
488            try {
489                img = new Image (iconData, iformat);
490            } catch (Exception e) {
491                gres.addHint(cpt.getId (), "image-write-error", ["fname": baseName (iconPath), "pkg_fname": baseName (sourcePkg.filename), "error": e.msg]);
492                return false;
493            }
494
495            if (iformat == ImageFormat.XPM) {
496                // we use XPM images only if they are large enough
497                if ((img.width < size.width) || (img.height < size.height))
498                    return false;
499            }
500
501            // create target directory
502            mkdirRecurse (path);
503
504            try {
505                img.scale (size.width, size.height);
506                img.savePng (iconStoreLocation);
507            } catch (Exception e) {
508                gres.addHint(cpt.getId (), "image-write-error", ["fname": baseName (iconPath), "pkg_fname": baseName (sourcePkg.filename), "error": e.msg]);
509                return false;
510            }
511
512            delete img;
513        }
514
515        auto icon = new Icon ();
516        icon.setKind (IconKind.CACHED);
517        icon.setWidth (size.width);
518        icon.setHeight (size.height);
519        icon.setName (iconName);
520        cpt.addIcon (icon);
521
522        return true;
523    }
524
525    bool process (GeneratorResult gres, Component cpt)
526    {
527        auto iconName = getIconNameAndClear (cpt);
528        // nothing to do if there is no icon
529        if (iconName is null)
530            return true;
531
532        auto gcid = gres.gcidForComponent (cpt);
533        if (gcid is null) {
534            auto cid = cpt.getId ();
535            if (cid is null)
536                cid = "general";
537            gres.addHint (cid, "internal-error", "No global ID could be found for the component.");
538            return false;
539        }
540
541        auto cptMediaPath = buildPath (mediaExportPath, gcid);
542
543        if (iconName.startsWith ("/")) {
544            if (gres.pkg.contents.canFind (iconName))
545                return storeIcon (cpt, gres, cptMediaPath, gres.pkg, iconName, ImageSize (64, 64));
546        } else {
547            iconName  = baseName (iconName);
548
549
550            // Small hack: Strip .png and other extensions from icon files to make the XDG and Pixmap finder
551            // work properly, which add their own icon extensions and find the most suitable icon.
552            iconName = stripIconExt (iconName);
553
554            string lastIconName = null;
555            /// Search for an icon in XDG icon directories.
556            /// Returns true on success and sets lastIconName to the
557            /// last icon name that has been handled.
558            bool findAndStoreXdgIcon (Package epkg = null)
559            {
560                auto iconRes = findIcons (iconName, wantedIconSizes, epkg);
561                if (iconRes is null)
562                    return false;
563
564                IconFindResult[ImageSize] iconsStored;
565                foreach (size; wantedIconSizes) {
566                    auto infoP = (size in iconRes);
567
568                    IconFindResult info;
569                    info.pkg = null;
570                    if (infoP !is null)
571                        info = *infoP;
572
573                    if (info.pkg is null) {
574                        // the size we want wasn't found, can we downscale a larger one?
575                        foreach (asize; iconRes.byKey ()) {
576                            auto data = iconRes[asize];
577                            if (asize < size)
578                                continue;
579                            info = data;
580                            break;
581                        }
582                    }
583
584                    // give up if we still haven't found an icon
585                    if (info.pkg is null)
586                        continue;
587
588                    lastIconName = info.fname;
589                    if (iconAllowed (lastIconName)) {
590                        if (storeIcon (cpt, gres, cptMediaPath, info.pkg, lastIconName, size))
591                            iconsStored[size] = info;
592                    } else {
593                        // the found icon is not suitable, but maybe a larger one is available that we can downscale?
594                        foreach (asize; iconRes.byKey ()) {
595                            auto data = iconRes[asize];
596                            if (asize < size)
597                                continue;
598                            info = data;
599                            break;
600                        }
601
602                        if (iconAllowed (info.fname)) {
603                            if (storeIcon (cpt, gres, cptMediaPath, info.pkg, lastIconName, size))
604                                iconsStored[size] = info;
605                            lastIconName = info.fname;
606                        }
607                    }
608                }
609
610                // ensure we have stored a 64x64px icon, since this is mandated
611                // by the AppStream spec by downscaling a larger icon that we
612                // might have found.
613                if (ImageSize(64) !in iconsStored) {
614                    foreach (size; wantedIconSizes) {
615                        if (size !in iconsStored)
616                            continue;
617                        if (size < ImageSize(64))
618                            continue;
619                        auto info = iconsStored[size];
620                        lastIconName = info.fname;
621                        if (storeIcon (cpt, gres, cptMediaPath, info.pkg, lastIconName, ImageSize(64)))
622                            return true;
623                    }
624                } else {
625                    return true;
626                }
627
628                return false;
629            }
630
631            // search for the right icon iside the current package
632            auto success = findAndStoreXdgIcon (gres.pkg);
633            if ((!success) && (!gres.isIgnored (cpt))) {
634                // search in all packages
635                success = findAndStoreXdgIcon ();
636                if (success) {
637                    // we found a valid stock icon, so set that additionally to the cached one
638                    auto icon = new Icon ();
639                    icon.setKind (IconKind.STOCK);
640                    icon.setName (iconName);
641                    cpt.addIcon (icon);
642                } else if ((lastIconName !is null) && (!iconAllowed (lastIconName))) {
643                    gres.addHint (cpt.getId (), "icon-format-unsupported", ["icon_fname": baseName (lastIconName)]);
644                }
645            }
646
647            if ((!success) && (lastIconName is null)) {
648                gres.addHint (cpt.getId (), "icon-not-found", ["icon_fname": iconName]);
649                return false;
650            }
651
652        }
653
654        return true;
655    }
656}
657
658unittest
659{
660    writeln ("TEST: ", "IconHandler");
661
662    auto hicolorThemeIndex = getDataPath ("hicolor-theme-index.theme");
663    ubyte[] indexData;
664    auto f = File (hicolorThemeIndex, "r");
665    while (!f.eof) {
666        char[GENERIC_BUFFER_SIZE] buf;
667        indexData ~= f.rawRead (buf);
668    }
669
670    auto theme = new Theme ("hicolor", indexData);
671    foreach (fname; theme.matchingIconFilenames ("accessories-calculator", ImageSize (48))) {
672        bool valid = false;
673        if (fname.startsWith ("/usr/share/icons/hicolor/48x48/"))
674            valid = true;
675        if (fname.startsWith ("/usr/share/icons/hicolor/scalable/"))
676            valid = true;
677        assert (valid);
678
679        if ((valid) && (IconHandler.iconAllowed (fname)))
680            valid = true;
681        else
682            valid = false;
683
684        if (fname.endsWith (".ico"))
685            assert (!valid);
686        else
687            assert (valid);
688    }
689}
Note: See TracBrowser for help on using the repository browser.