diff options
Diffstat (limited to '')
-rw-r--r-- | share/extensions/inkex/gui/pixmap.py | 346 |
1 files changed, 346 insertions, 0 deletions
diff --git a/share/extensions/inkex/gui/pixmap.py b/share/extensions/inkex/gui/pixmap.py new file mode 100644 index 0000000..02f4ce8 --- /dev/null +++ b/share/extensions/inkex/gui/pixmap.py @@ -0,0 +1,346 @@ +# +# Copyright 2011-2022 Martin Owens <doctormo@geek-2.com> +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/> +# +""" +Provides wrappers for pixmap access. +""" + +import os +import logging + +from typing import List +from collections.abc import Iterable +from gi.repository import Gtk, GLib, GdkPixbuf + +ICON_THEME = Gtk.IconTheme.get_default() +BILINEAR = GdkPixbuf.InterpType.BILINEAR +HYPER = GdkPixbuf.InterpType.HYPER + +SIZE_ASPECT = 0 +SIZE_ASPECT_GROW = 1 +SIZE_ASPECT_CROP = 2 +SIZE_STRETCH = 3 + + +class PixmapLoadError(ValueError): + """Failed to load a pixmap""" + + +class PixmapFilter: # pylint: disable=too-few-public-methods + """Base class for filtering the pixmaps in a manager's output. + + required - List of values required for this filter. + + Use: + + class Foo(PixmapManager): + filters = [ PixmapFilterFoo ] + + """ + + required: List[str] = [] + optional: List[str] = [] + + def __init__(self, **kwargs): + self.enabled = True + for key in self.required: + if key not in kwargs: + self.enabled = False + else: + setattr(self, key, kwargs[key]) + + for key in self.optional: + if key in kwargs: + setattr(self, key, kwargs[key]) + + def filter(self, img, **kwargs): + """Run filter, replace this methodwith your own""" + raise NotImplementedError( + "Please add 'filter' method to your PixmapFilter class %s." + % type(self).__name__ + ) + + @staticmethod + def to_size(dat): + """Tries to calculate a size that will work for the data""" + if isinstance(dat, (int, float)): + return (dat, dat) + if isinstance(dat, Iterable) and len(dat) >= 2: + return (dat[0], dat[1]) + return None + + +class OverlayFilter(PixmapFilter): + """Adds an overlay to output images, overlay can be any name that + the owning pixmap manager can find. + + overlay : Name of overlay image + position : Location of the image: + 0 - Full size (1 to 1 overlay, default) + (x,y) - Percentage from one end to the other position 0-1 + alpha : Blending alpha, 0 - 255 + + """ + + optional = ["position", "overlay", "alpha"] + + def __init__(self, *args, **kwargs): + self.position = (0, 0) + self.overlay = None + self.alpha = 255 + super().__init__(*args, **kwargs) + self.pad_x, self.pad_y = self.to_size(self.position) + + def get_overlay(self, **kwargs): + if "manager" not in kwargs: + raise ValueError("PixmapManager must be provided when adding an overlay.") + return kwargs["manager"].get( + kwargs.get("overlay", None) or self.overlay, no_overlay=True + ) + + def filter(self, img, no_overlay=False, **kwargs): + # Recursion protection + if no_overlay: + return img + + overlay = self.get_overlay(**kwargs) + if overlay: + img = img.copy() + + (x, y, width, height) = self.set_position(overlay, img) + overlay.composite( + img, x, y, width, height, x, y, 1, 1, BILINEAR, self.alpha + ) + return img + + def set_position(self, overlay, img): + """Sets the position of img on the given width and height""" + img_w, img_h = img.get_width(), img.get_height() + ovl_w, ovl_h = overlay.get_width(), overlay.get_height() + return ( + max([0, (img_w - ovl_w) * self.pad_x]), + max([0, (img_h - ovl_h) * self.pad_y]), + min([ovl_w, img_w]), + min([ovl_h, img_h]), + ) + + +class SizeFilter(PixmapFilter): + """Resizes images to a certain size: + + resize_mode - Way in which the size is calculated + 0 - Best Aspect, don't grow + 1 - Best Aspect, grow + 2 - Cropped Aspect + 3 - Stretch + """ + + required = ["size"] + optional = ["resize_mode"] + + def __init__(self, *args, **kwargs): + self.size = None + self.resize_mode = SIZE_ASPECT + super().__init__(*args, **kwargs) + self.img_w, self.img_h = self.to_size(self.size) or (0, 0) + + def aspect(self, img_w, img_h): + """Get the aspect ratio of the image resized""" + if self.resize_mode == SIZE_STRETCH: + return (self.img_w, self.img_h) + + if ( + self.resize_mode == SIZE_ASPECT + and img_w < self.img_w + and img_h < self.img_h + ): + return (img_w, img_h) + (pcw, pch) = (self.img_w / img_w, self.img_h / img_h) + factor = ( + max(pcw, pch) if self.resize_mode == SIZE_ASPECT_CROP else min(pcw, pch) + ) + return (int(img_w * factor), int(img_h * factor)) + + def filter(self, img, **kwargs): + if self.size is not None: + (width, height) = self.aspect(img.get_width(), img.get_height()) + return img.scale_simple(width, height, HYPER) + return img + + +class PadFilter(SizeFilter): + """Add padding to the image to make it a standard size""" + + optional = ["padding"] + + def __init__(self, *args, **kwargs): + self.size = None + self.padding = 0.5 + super().__init__(*args, **kwargs) + self.pad_x, self.pad_y = self.to_size(self.padding) + + def filter(self, img, **kwargs): + (width, height) = (img.get_width(), img.get_height()) + if width < self.img_w or height < self.img_h: + target = GdkPixbuf.Pixbuf.new( + img.get_colorspace(), + True, + img.get_bits_per_sample(), + max([width, self.img_w]), + max([height, self.img_h]), + ) + target.fill(0x0) # Transparent black + + x = (target.get_width() - width) * self.pad_x + y = (target.get_height() - height) * self.pad_y + + img.composite(target, x, y, width, height, x, y, 1, 1, BILINEAR, 255) + return target + return img + + +class PixmapManager: + """Manage a set of cached pixmaps, returns the default image + if it can't find one or the missing image if that's available.""" + + missing_image = "image-missing" + default_image = "application-default-icon" + icon_theme = ICON_THEME + theme_size = 32 + filters: List[type] = [] + pixmap_dir = None + + def __init__(self, location="", **kwargs): + self.location = location + if self.pixmap_dir and not os.path.isabs(location): + self.location = os.path.join(self.pixmap_dir, location) + + self.loader_size = PixmapFilter.to_size(kwargs.pop("load_size", None)) + + # Add any instance specified filters first + self._filters = [] + for item in kwargs.get("filters", []) + self.filters: + if isinstance(item, PixmapFilter): + self._filters.append(item) + elif callable(item): + # Now add any class specified filters with optional kwargs + self._filters.append(item(**kwargs)) + + self.cache = {} + self.get_pixmap(self.default_image) + + def get(self, *args, **kwargs): + """Get a pixmap of any kind""" + return self.get_pixmap(*args, **kwargs) + + def get_missing_image(self): + """Get a missing image when other images aren't found""" + return self.get(self.missing_image) + + @staticmethod + def data_is_file(data): + """Test the file to see if it's a filename or not""" + return isinstance(data, str) and "<svg" not in data + + def get_pixmap(self, data, **kwargs): + """ + There are three types of images this might return. + + 1. A named gtk-image such as "gtk-stop" + 2. A file on the disk such as "/tmp/a.png" + 3. Data as either svg or binary png + + All pixmaps are cached for multiple use. + """ + if "manager" not in kwargs: + kwargs["manager"] = self + + if not data: + if not self.default_image: + return None + data = self.default_image + + key = data[-30:] # bytes or string + if not key in self.cache: + # load the image from data or a filename/theme icon + img = None + try: + if self.data_is_file(data): + img = self.load_from_name(data) + else: + img = self.load_from_data(data) + except PixmapLoadError as err: + logging.warning(str(err)) + return self.get_missing_image() + + if img is not None: + self.cache[key] = self.apply_filters(img, **kwargs) + + return self.cache[key] + + def apply_filters(self, img, **kwargs): + """Apply all the filters to the given image""" + for lens in self._filters: + if lens.enabled: + img = lens.filter(img, **kwargs) + return img + + def load_from_data(self, data): + """Load in memory picture file (jpeg etc)""" + # This doesn't work yet, returns None *shrug* + loader = GdkPixbuf.PixbufLoader() + if self.loader_size: + loader.set_size(*self.loader_size) + try: + if isinstance(data, str): + data = data.encode("utf-8") + loader.write(data) + loader.close() + except GLib.GError as err: + raise PixmapLoadError(f"Faled to load pixbuf from data: {err}") + return loader.get_pixbuf() + + def load_from_name(self, name): + """Load a pixbuf from a name, filename or theme icon name""" + pixmap_path = self.pixmap_path(name) + if os.path.exists(pixmap_path): + try: + return GdkPixbuf.Pixbuf.new_from_file(pixmap_path) + except RuntimeError as msg: + raise PixmapLoadError(f"Faild to load pixmap '{pixmap_path}', {msg}") + elif ( + self.icon_theme and "/" not in name and "." not in name and "<" not in name + ): + return self.theme_pixmap(name, size=self.theme_size) + raise PixmapLoadError(f"Failed to find pixmap '{name}' in {self.location}") + + def theme_pixmap(self, name, size=32): + """Internal user: get image from gnome theme""" + size = size or 32 + if not self.icon_theme.has_icon(name): + name = "image-missing" + return self.icon_theme.load_icon(name, size, 0) + + def pixmap_path(self, name): + """Returns the pixmap path based on stored location""" + for filename in ( + name, + os.path.join(self.location, f"{name}.svg"), + os.path.join(self.location, f"{name}.png"), + ): + if os.path.exists(filename) and os.path.isfile(filename): + return name + return os.path.join(self.location, name) |