diff options
Diffstat (limited to '')
-rw-r--r-- | share/extensions/inkex/extensions.py | 475 |
1 files changed, 475 insertions, 0 deletions
diff --git a/share/extensions/inkex/extensions.py b/share/extensions/inkex/extensions.py new file mode 100644 index 0000000..37ead3d --- /dev/null +++ b/share/extensions/inkex/extensions.py @@ -0,0 +1,475 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2018 Martin Owens <doctormo@gmail.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 2 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, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +""" +A helper module for creating Inkscape effect extensions + +This provides the basic generic types of extensions which most writers should +use in their code. See below for the different types. +""" + +import os +import re +import sys +import types +from abc import ABC + +from .utils import errormsg, Boolean +from .colors import Color, ColorError +from .elements import ( + load_svg, + BaseElement, + ShapeElement, + Group, + Layer, + Grid, + TextElement, + FlowPara, + FlowDiv, + Pattern, +) +from .elements._utils import CloningVat +from .base import ( + InkscapeExtension, + SvgThroughMixin, + SvgInputMixin, + SvgOutputMixin, + TempDirMixin, +) +from .transforms import Transform +from .elements import LinearGradient, RadialGradient + +# All the names that get added to the inkex API itself. +__all__ = ( + "EffectExtension", + "GenerateExtension", + "InputExtension", + "OutputExtension", + "RasterOutputExtension", + "CallExtension", + "TemplateExtension", + "ColorExtension", + "TextExtension", +) + +stdout = sys.stdout + + +class EffectExtension(SvgThroughMixin, InkscapeExtension, ABC): + """ + Takes the SVG from Inkscape, modifies the selection or the document + and returns an SVG to Inkscape. + """ + + +class OutputExtension(SvgInputMixin, InkscapeExtension): + """ + Takes the SVG from Inkscape and outputs it to something that's not an SVG. + + Used in functions for `Save As` + """ + + def effect(self): + """Effect isn't needed for a lot of Output extensions""" + + def save(self, stream): + """But save certainly is, we give a more exact message here""" + raise NotImplementedError("Output extensions require a save(stream) method!") + + +class RasterOutputExtension(InkscapeExtension): + """ + Takes a PNG from Inkscape and outputs it to another rather format. + + .. versionadded:: 1.1 + """ + + def __init__(self): + super().__init__() + self.img = None + + def load(self, stream): + from PIL import Image + + # disable the PIL decompression bomb DOS attack check. + Image.MAX_IMAGE_PIXELS = None + + self.img = Image.open(stream) + + def effect(self): + """Not needed since image isn't being changed""" + + def save(self, stream): + """Implement raster image saving here from PIL""" + raise NotImplementedError("Raster Output extension requires a save method!") + + +class InputExtension(SvgOutputMixin, InkscapeExtension): + """ + Takes any type of file as input and outputs SVG which Inkscape can read. + + Used in functions for `Open` + """ + + def effect(self): + """Effect isn't needed for a lot of Input extensions""" + + def load(self, stream): + """But load certainly is, we give a more exact message here""" + raise NotImplementedError("Input extensions require a load(stream) method!") + + +class CallExtension(TempDirMixin, InputExtension): + """Call an external program to get the output""" + + input_ext = "svg" + output_ext = "svg" + + def load(self, stream): + pass # Not called (load_raw instead) + + def load_raw(self): + # Don't call InputExtension.load_raw + TempDirMixin.load_raw(self) + input_file = self.options.input_file + + if not isinstance(input_file, str): + data = input_file.read() + input_file = os.path.join(self.tempdir, "input." + self.input_ext) + with open(input_file, "wb") as fhl: + fhl.write(data) + + output_file = os.path.join(self.tempdir, "output." + self.output_ext) + document = self.call(input_file, output_file) or output_file + if isinstance(document, str): + if not os.path.isfile(document): + raise IOError(f"Can't find generated document: {document}") + + if self.output_ext == "svg": + with open(document, "r", encoding="utf-8") as fhl: + document = fhl.read() + if "<" in document: + document = load_svg(document.encode("utf-8")) + else: + with open(document, "rb") as fhl: + document = fhl.read() + + self.document = document + + def call(self, input_file, output_file): + """Call whatever programs are needed to get the desired result.""" + raise NotImplementedError("Call extensions require a call(in, out) method!") + + +class GenerateExtension(EffectExtension): + """ + Does not need any SVG, but instead just outputs an SVG fragment which is + inserted into Inkscape, centered on the selection. + """ + + container_label = "" + container_layer = False + + def generate(self): + """ + Return an SVG fragment to be inserted into the selected layer of the document + OR yield multiple elements which will be grouped into a container group + element which will be given an automatic label and transformation. + """ + raise NotImplementedError("Generate extensions must provide generate()") + + def container_transform(self): + """ + Generate the transformation for the container group, the default is + to return the center position of the svg document or view port. + """ + (pos_x, pos_y) = self.svg.namedview.center + if pos_x is None: + pos_x = 0 + if pos_y is None: + pos_y = 0 + return Transform(translate=(pos_x, pos_y)) + + def create_container(self): + """ + Return the container the generated elements will go into. + + Default is a new layer or current layer depending on the :attr:`container_layer` + flag. + + .. versionadded:: 1.1 + """ + container = (Layer if self.container_layer else Group).new(self.container_label) + if self.container_layer: + self.svg.append(container) + else: + container.transform = self.container_transform() + parent = self.svg.get_current_layer() + try: + parent_transform = parent.composed_transform() + except AttributeError: + pass + else: + container.transform = -parent_transform @ container.transform + parent.append(container) + return container + + def effect(self): + layer = self.svg.get_current_layer() + fragment = self.generate() + if isinstance(fragment, types.GeneratorType): + container = self.create_container() + for child in fragment: + if isinstance(child, BaseElement): + container.append(child) + elif isinstance(fragment, BaseElement): + layer.append(fragment) + else: + errormsg("Nothing was generated\n") + + +class TemplateExtension(EffectExtension): + """ + Provide a standard way of creating templates. + """ + + size_rex = re.compile(r"([\d.]*)(\w\w)?x([\d.]*)(\w\w)?") + template_id = "SVGRoot" + + def __init__(self): + self.svg = None + super().__init__() + # Arguments added on after add_arguments so it can be overloaded cleanly. + self.arg_parser.add_argument("--size", type=self.arg_size(), dest="size") + self.arg_parser.add_argument("--width", type=int, default=800) + self.arg_parser.add_argument("--height", type=int, default=600) + self.arg_parser.add_argument("--orientation", default=None) + self.arg_parser.add_argument("--unit", default="px") + self.arg_parser.add_argument("--grid", type=Boolean) + # self.svg = None + + def get_template(self, **kwargs): + """Can be over-ridden with custom svg loading here""" + return self.document + + def arg_size(self, unit="px"): + """Argument is a string of the form X[unit]xY[unit], default units apply + when missing""" + + def _inner(value): + try: + value = float(value) + return (value, unit, value, unit) + except ValueError: + pass + match = self.size_rex.match(str(value)) + if match is not None: + size = match.groups() + return ( + float(size[0]), + size[1] or unit, + float(size[2]), + size[3] or unit, + ) + return None + + return _inner + + def get_size(self): + """Get the size of the new template (defaults to size options)""" + size = self.options.size + if self.options.size is None: + size = ( + self.options.width, + self.options.unit, + self.options.height, + self.options.unit, + ) + if ( + self.options.orientation == "horizontal" + and size[0] < size[2] + or self.options.orientation == "vertical" + and size[0] > size[2] + ): + size = size[2:4] + size[0:2] + return size + + def effect(self): + """Creates a template, do not over-ride""" + (width, width_unit, height, height_unit) = self.get_size() + width_px = int(self.svg.uutounit(width, "px")) + height_px = int(self.svg.uutounit(height, "px")) + + self.document = self.get_template() + self.svg = self.document.getroot() + self.svg.set("id", self.template_id) + self.svg.set("width", str(width) + width_unit) + self.svg.set("height", str(height) + height_unit) + self.svg.set("viewBox", f"0 0 {width} {height}") + self.set_namedview(width_px, height_px, width_unit) + + def set_namedview(self, width, height, unit): + """Setup the document namedview""" + self.svg.namedview.set("inkscape:document-units", unit) + self.svg.namedview.set("inkscape:zoom", "0.25") + self.svg.namedview.set("inkscape:cx", str(width / 2.0)) + self.svg.namedview.set("inkscape:cy", str(height / 2.0)) + if self.options.grid: + self.svg.namedview.set("showgrid", "true") + self.svg.namedview.add(Grid(type="xygrid")) + + +class ColorExtension(EffectExtension): + """ + A standard way to modify colours in an svg document. + """ + + process_none = False # should we call modify_color for the "none" color. + select_all = (ShapeElement,) + pass_rgba = False + """ + If true, color and opacity are processed together (as RGBA color) + by :func:`modify_color`. + + If false (default), they are processed independently by `modify_color` and + `modify_opacity`. + + .. versionadded:: 1.2 + """ + + def __init__(self): + super().__init__() + self._renamed = {} + + def effect(self): + # Limiting to shapes ignores Gradients (and other things) from the select_all + # this prevents defs from being processed twice. + self._renamed = {} + gradients = CloningVat(self.svg) + for elem in self.svg.selection.get(ShapeElement): + self.process_element(elem, gradients) + gradients.process(self.process_elements, types=(ShapeElement,)) + + def process_elements(self, elem): + """Process multiple elements (gradients)""" + for child in elem.descendants(): + self.process_element(child) + + def process_element(self, elem, gradients=None): + """Process one of the selected elements""" + style = elem.specified_style() + # Colours first + for name in ( + elem.style.associated_props if self.pass_rgba else elem.style.color_props + ): + if name not in style: + continue # we don't want to process default values + try: + value = style(name) + except ColorError: + continue # bad color value, don't touch. + if isinstance(value, Color): + col = Color(value) + if self.pass_rgba: + col = col.to_rgba( + alpha=elem.style(elem.style.associated_props[name]) + ) + rgba_result = self._modify_color(name, col) + elem.style.set_color(rgba_result, name) + + if isinstance(value, (LinearGradient, RadialGradient, Pattern)): + gradients.track(value, elem, self._ref_cloned, style=style, name=name) + if value.href is not None: + gradients.track(value.href, elem, self._xlink_cloned, linker=value) + # Then opacities (usually does nothing) + if self.pass_rgba: + return + for name in elem.style.opacity_props: + value = style(name) + result = self.modify_opacity(name, value) + if result not in (value, 1): # only modify if not equal to old or default + elem.style[name] = result + + def _ref_cloned(self, old_id, new_id, style, name): + self._renamed[old_id] = new_id + style[name] = f"url(#{new_id})" + + def _xlink_cloned(self, old_id, new_id, linker): # pylint: disable=unused-argument + lid = linker.get("id") + linker = self.svg.getElementById(self._renamed.get(lid, lid)) + linker.set("xlink:href", "#" + new_id) + + def _modify_color(self, name, color): + """Pre-process color value to filter out bad colors""" + if color or self.process_none: + return self.modify_color(name, color) + return color + + def modify_color(self, name, color): + """Replace this method with your colour modifier method""" + raise NotImplementedError("Provide a modify_color method.") + + def modify_opacity( + self, name, opacity + ): # pylint: disable=no-self-use, unused-argument + """Optional opacity modification""" + return opacity + + +class TextExtension(EffectExtension): + """ + A base effect for changing text in a document. + """ + + newline = True + newpar = True + + def effect(self): + nodes = self.svg.selection or {None: self.document.getroot()} + for elem in nodes.values(): + self.process_element(elem) + + def process_element(self, node): + """Reverse the node text""" + if node.get("sodipodi:role") == "line": + self.newline = True + elif isinstance(node, (TextElement, FlowPara, FlowDiv)): + self.newline = True + self.newpar = True + + if node.text is not None: + node.text = self.process_chardata(node.text) + self.newline = False + self.newpar = False + + for child in node: + self.process_element(child) + + if node.tail is not None: + node.tail = self.process_chardata(node.tail) + + def process_chardata(self, text): + """Replaceable chardata method for processing the text""" + return "".join(map(self.map_char, text)) + + @staticmethod + def map_char(char): + """Replaceable map_char method for processing each letter""" + raise NotImplementedError( + "Please provide a process_chardata or map_char static method." + ) |