1# -*- coding: utf-8 -*-
2#
3# Copyright (C) 2018 Martin Owens <doctormo@gmail.com>
4#
5# This program is free software; you can redistribute it and/or modify
6# it under the terms of the GNU General Public License as published by
7# the Free Software Foundation; either version 2 of the License, or
8# (at your option) any later version.
9#
10# This program is distributed in the hope that it will be useful,
11# but WITHOUT ANY WARRANTY; without even the implied warranty of
12# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13# GNU General Public License for more details.
14#
15# You should have received a copy of the GNU General Public License
16# along with this program; if not, write to the Free Software
17# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
18#
19"""
20A helper module for creating Inkscape effect extensions
21
22This provides the basic generic types of extensions which most writers should
23use in their code. See below for the different types.
24"""
25
26import os
27import re
28import sys
29import types
30
31from .utils import errormsg, Boolean
32from .colors import Color, ColorIdError, ColorError
33from .elements import load_svg, BaseElement, ShapeElement, Group, Layer, Grid, \
34                      TextElement, FlowPara, FlowDiv
35from .elements._utils import CloningVat
36from .base import InkscapeExtension, SvgThroughMixin, SvgInputMixin, SvgOutputMixin, TempDirMixin
37from .transforms import Transform
38
39# All the names that get added to the inkex API itself.
40__all__ = ('EffectExtension', 'GenerateExtension', 'InputExtension',
41           'OutputExtension', 'RasterOutputExtension',
42           'CallExtension', 'TemplateExtension', 'ColorExtension', 'TextExtension')
43
44stdout = sys.stdout
45
46
47class EffectExtension(SvgThroughMixin, InkscapeExtension):
48    """
49    Takes the SVG from Inkscape, modifies the selection or the document
50    and returns an SVG to Inkscape.
51    """
52    pass
53
54class OutputExtension(SvgInputMixin, InkscapeExtension):
55    """
56    Takes the SVG from Inkscape and outputs it to something that's not an SVG.
57
58    Used in functions for `Save As`
59    """
60    def effect(self):
61        """Effect isn't needed for a lot of Output extensions"""
62        pass
63
64    def save(self, stream):
65        """But save certainly is, we give a more exact message here"""
66        raise NotImplementedError("Output extensions require a save(stream) method!")
67
68class RasterOutputExtension(InkscapeExtension):
69    """
70    Takes a PNG from Inkscape and outputs it to another rather format.
71    """
72    def load(self, stream):
73        from PIL import Image
74        self.img = Image.open(stream)
75
76    def effect(self):
77        """Not needed since image isn't being changed"""
78        pass
79
80    def save(self, stream):
81        """Implement raster image saving here from PIL"""
82        raise NotImplementedError("Raster Output extension requires a save method!")
83
84
85class InputExtension(SvgOutputMixin, InkscapeExtension):
86    """
87    Takes any type of file as input and outputs SVG which Inkscape can read.
88
89    Used in functions for `Open`
90    """
91    def effect(self):
92        """Effect isn't needed for a lot of Input extensions"""
93        pass
94
95    def load(self, stream):
96        """But load certainly is, we give a more exact message here"""
97        raise NotImplementedError("Input extensions require a load(stream) method!")
98
99class CallExtension(TempDirMixin, InputExtension):
100    """Call an external program to get the output"""
101    input_ext = 'svg'
102    output_ext = 'svg'
103
104    def load(self, stream):
105        pass # Not called (load_raw instead)
106
107    def load_raw(self):
108        # Don't call InputExtension.load_raw
109        TempDirMixin.load_raw(self)
110        input_file = self.options.input_file
111
112        if not isinstance(input_file, str):
113            data = input_file.read()
114            input_file = os.path.join(self.tempdir, 'input.' + self.input_ext)
115            with open(input_file, 'wb') as fhl:
116                fhl.write(data)
117
118        output_file = os.path.join(self.tempdir, 'output.' + self.output_ext)
119        document = self.call(input_file, output_file) or output_file
120        if isinstance(document, str):
121            if not os.path.isfile(document):
122                raise IOError(f"Can't find generated document: {document}")
123
124            if self.output_ext == 'svg':
125                with open(document, 'r') as fhl:
126                    document = fhl.read()
127                if '<' in document:
128                    document = load_svg(document.encode('utf-8'))
129            else:
130                with open(document, 'rb') as fhl:
131                    document = fhl.read()
132
133        self.document = document
134
135    def call(self, input_file, output_file):
136        """Call whatever programs are needed to get the desired result."""
137        raise NotImplementedError("Call extensions require a call(in, out) method!")
138
139class GenerateExtension(EffectExtension):
140    """
141    Does not need any SVG, but instead just outputs an SVG fragment which is
142    inserted into Inkscape, centered on the selection.
143    """
144    container_label = ''
145    container_layer = False
146
147    def generate(self):
148        """
149        Return an SVG fragment to be inserted into the selected layer of the document
150        OR yield multiple elements which will be grouped into a container group
151        element which will be given an automatic label and transformation.
152        """
153        raise NotImplementedError("Generate extensions must provide generate()")
154
155    def container_transform(self):
156        """
157        Generate the transformation for the container group, the default is
158        to return the center position of the svg document or view port.
159        """
160        (pos_x, pos_y) = self.svg.namedview.center
161        if pos_x is None:
162            pos_x = 0
163        if pos_y is None:
164            pos_y = 0
165        return Transform(translate=(pos_x, pos_y))
166
167    def create_container(self):
168        """
169        Return the container the generated elements will go into.
170
171        Default is a new layer or current layer depending on the container_layer flag.
172        """
173        container = (Layer if self.container_layer else Group).new(self.container_label)
174        if self.container_layer:
175            self.svg.append(container)
176        else:
177            container.transform = self.container_transform()
178            parent = self.svg.get_current_layer()
179            try:
180                parent_transform = parent.composed_transform()
181            except AttributeError:
182                pass
183            else:
184                container.transform = -parent_transform * container.transform
185            parent.append(container)
186        return container
187
188    def effect(self):
189        layer = self.svg.get_current_layer()
190        fragment = self.generate()
191        if isinstance(fragment, types.GeneratorType):
192            container = self.create_container()
193            for child in fragment:
194                if isinstance(child, BaseElement):
195                    container.append(child)
196        elif isinstance(fragment, BaseElement):
197            layer.append(fragment)
198        else:
199            errormsg("Nothing was generated\n")
200
201
202class TemplateExtension(EffectExtension):
203    """
204    Provide a standard way of creating templates.
205    """
206    size_rex = re.compile(r'([\d.]*)(\w\w)?x([\d.]*)(\w\w)?')
207    template_id = "SVGRoot"
208
209    def __init__(self):
210        super().__init__()
211        # Arguments added on after add_arguments so it can be overloaded cleanly.
212        self.arg_parser.add_argument("--size", type=self.arg_size(), dest="size")
213        self.arg_parser.add_argument("--width", type=int, default=800)
214        self.arg_parser.add_argument("--height", type=int, default=600)
215        self.arg_parser.add_argument("--orientation", default=None)
216        self.arg_parser.add_argument("--unit", default="px")
217        self.arg_parser.add_argument("--grid", type=Boolean)
218
219    def get_template(self):
220        """Can be over-ridden with custom svg loading here"""
221        return self.document
222
223    def arg_size(self, unit='px'):
224        """Argument is a string of the form X[unit]xY[unit], default units apply when missing"""
225        def _inner(value):
226            try:
227                value = float(value)
228                return (value, unit, value, unit)
229            except ValueError:
230                pass
231            match = self.size_rex.match(str(value))
232            if match is not None:
233                size = match.groups()
234                return (float(size[0]), size[1] or unit, float(size[2]), size[3] or unit)
235            return None
236        return _inner
237
238    def get_size(self):
239        """Get the size of the new template (defaults to size options)"""
240        size = self.options.size
241        if self.options.size is None:
242            size = (self.options.width, self.options.unit,
243                    self.options.height, self.options.unit)
244        if self.options.orientation == "horizontal" and size[0] < size[2] \
245                or self.options.orientation == "vertical" and size[0] > size[2]:
246            size = size[2:4] + size[0:2]
247        return size
248
249    def effect(self):
250        """Creates a template, do not over-ride"""
251        (width, width_unit, height, height_unit) = self.get_size()
252        width_px = int(self.svg.uutounit(width, 'px'))
253        height_px = int(self.svg.uutounit(height, 'px'))
254
255        self.document = self.get_template()
256        self.svg = self.document.getroot()
257        self.svg.set("id", self.template_id)
258        self.svg.set("width", str(width) + width_unit)
259        self.svg.set("height", str(height) + height_unit)
260        self.svg.set("viewBox", f"0 0 {width} {height}")
261        self.set_namedview(width_px, height_px, width_unit)
262
263    def set_namedview(self, width, height, unit):
264        """Setup the document namedview"""
265        self.svg.namedview.set('inkscape:document-units', unit)
266        self.svg.namedview.set('inkscape:zoom', '0.25')
267        self.svg.namedview.set('inkscape:cx', str(width / 2.0))
268        self.svg.namedview.set('inkscape:cy', str(height / 2.0))
269        if self.options.grid:
270            self.svg.namedview.set('showgrid', "true")
271            self.svg.namedview.add(Grid(type="xygrid"))
272
273
274class ColorExtension(EffectExtension):
275    """
276    A standard way to modify colours in an svg document.
277    """
278    process_none = False # should we call modify_color for the "none" color.
279    select_all = (ShapeElement,)
280
281    def effect(self):
282        # Limiting to shapes ignores Gradients (and other things) from the select_all
283        # this prevents defs from being processed twice.
284        self._renamed = {}
285        gradients = CloningVat(self.svg)
286        for elem in self.svg.selection.get(ShapeElement):
287            self.process_element(elem, gradients)
288        gradients.process(self.process_elements, types=(ShapeElement,))
289
290    def process_elements(self, elem):
291        """Process multiple elements (gradients)"""
292        for child in elem.descendants():
293            self.process_element(child)
294
295    def process_element(self, elem, gradients=None):
296        """Process one of the selected elements"""
297        style = elem.fallback_style(move=False)
298        # Colours first
299        for name in elem.style.color_props:
300            value = style.get(name)
301            if value is not None:
302                try:
303                    style[name] = self._modify_color(name, Color(value))
304                except ColorIdError:
305                    gradient = self.svg.getElementById(value)
306                    gradients.track(gradient, elem, self._ref_cloned, style=style, name=name)
307                    if gradient.href is not None:
308                        gradients.track(gradient.href, elem, self._xlink_cloned, linker=gradient)
309                except ColorError:
310                    pass # bad color value, don't touch.
311        # Then opacities (usually does nothing)
312        for name in elem.style.opacity_props:
313            value = style.get(name)
314            if value is not None:
315                style[name] = self.modify_opacity(name, value)
316
317    def _ref_cloned(self, old_id, new_id, style, name):
318        self._renamed[old_id] = new_id
319        style[name] = f"url(#{new_id})"
320
321    def _xlink_cloned(self, old_id, new_id, linker):
322        lid = linker.get('id')
323        linker = self.svg.getElementById(self._renamed.get(lid, lid))
324        linker.set('xlink:href', '#' + new_id)
325
326    def _modify_color(self, name, color):
327        """Pre-process color value to filter out bad colors"""
328        if color or self.process_none:
329            return self.modify_color(name, color)
330        return color
331
332    def modify_color(self, name, color):
333        """Replace this method with your colour modifier method"""
334        raise NotImplementedError("Provide a modify_color method.")
335
336    def modify_opacity(self, name, opacity):
337        """Optional opacity modification"""
338        return opacity
339
340class TextExtension(EffectExtension):
341    """
342    A base effect for changing text in a document.
343    """
344    newline = True
345    newpar = True
346
347    def effect(self):
348        nodes = self.svg.selected or {None: self.document.getroot()}
349        for elem in nodes.values():
350            self.process_element(elem)
351
352    def process_element(self, node):
353        """Reverse the node text"""
354        if node.get('sodipodi:role') == 'line':
355            self.newline = True
356        elif isinstance(node, (TextElement, FlowPara, FlowDiv)):
357            self.newline = True
358            self.newpar = True
359
360        if node.text is not None:
361            node.text = self.process_chardata(node.text)
362            self.newline = False
363            self.newpar = False
364
365        for child in node:
366            self.process_element(child)
367
368        if node.tail is not None:
369            node.tail = self.process_chardata(node.tail)
370
371    def process_chardata(self, text):
372        """Replaceable chardata method for processing the text"""
373        return ''.join(map(self.map_char, text))
374
375    @staticmethod
376    def map_char(char):
377        """Replaceable map_char method for processing each letter"""
378        raise NotImplementedError("Please provide a process_chardata or map_char static method.")
379