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