#!/usr/bin/env python """ pathops.py - Inkscape extension to apply multiple path operations This extension takes a selection of path and a group of paths, or several paths, and applies a path operation with the top-most path in the z-order, and each selected path or each child of a selected group underneath. Copyright (C) 2014 Ryan Lerch (multiple difference) 2016 Maren Hachmann (refactoring, extend to multibool) 2017 su_v Rewrite to support large selections (process in chunks), to improve performance (support groups, z-sort ids with python instead of external query), and to extend GUI options. 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. """ # pylint: disable=too-many-ancestors # standard library import os from subprocess import Popen, PIPE import time # local library try: import inkex_local as inkex except ImportError: import inkex import simplestyle __version__ = '0.4' # Global "constants" SVG_SHAPES = ('rect', 'circle', 'ellipse', 'line', 'polyline', 'polygon') # ----- general helper functions def timed(f): """Minimalistic timer for functions.""" # pylint: disable=invalid-name start = time.time() ret = f() elapsed = time.time() - start return ret, elapsed # ----- SVG element helper functions def get_defs(node): """Find in children of *node*, return first one found.""" path = '/svg:svg//svg:defs' try: return node.xpath(path, namespaces=inkex.NSS)[0] except IndexError: return inkex.etree.SubElement(node, inkex.addNS('defs', 'svg')) def is_group(node): """Check node for group tag.""" return node.tag == inkex.addNS('g', 'svg') def is_path(node): """Check node for path tag.""" return node.tag == inkex.addNS('path', 'svg') def is_basic_shape(node): """Check node for SVG basic shape tag.""" return node.tag in (inkex.addNS(tag, 'svg') for tag in SVG_SHAPES) def is_custom_shape(node): """Check node for Inkscape custom shape type.""" return inkex.addNS('type', 'sodipodi') in node.attrib def is_shape(node): """Check node for SVG basic shape tag or Inkscape custom shape type.""" return is_basic_shape(node) or is_custom_shape(node) def has_path_effect(node): """Check node for Inkscape path-effect attribute.""" return inkex.addNS('path-effect', 'inkscape') in node.attrib def is_modifiable_path(node): """Check node for editable path data.""" return is_path(node) and not (has_path_effect(node) or is_custom_shape(node)) def is_image(node): """Check node for image tag.""" return node.tag == inkex.addNS('image', 'svg') def is_text(node): """Check node for text tag.""" return node.tag == inkex.addNS('text', 'svg') def does_pathops(node): """Check whether node is supported by Inkscape path operations.""" return (is_path(node) or is_shape(node) or is_text(node)) # ----- list processing helper functions def z_sort(node, alist): """Return new list sorted in document order (depth-first traversal).""" ordered = [] id_list = list(alist) count = len(id_list) for element in node.iter(): element_id = element.get('id') if element_id is not None and element_id in id_list: id_list.remove(element_id) ordered.append(element_id) count -= 1 if not count: break return ordered def z_iter(node, alist): """Return iterator over ids in document order (depth-first traversal).""" id_list = list(alist) for element in node.iter(): element_id = element.get('id') if element_id is not None and element_id in id_list: id_list.remove(element_id) yield element_id def chunks(alist, max_len): """Chunk a list into sublists of max_len length.""" for i in range(0, len(alist), max_len): yield alist[i:i+max_len] # ----- process external command, files def run(cmd_format, stdin_str=None, verbose=False): """Run command""" if verbose: inkex.debug(cmd_format) out = err = None myproc = Popen(cmd_format, shell=False, stdin=PIPE, stdout=PIPE, stderr=PIPE) out, err = myproc.communicate(stdin_str) if myproc.returncode == 0: return out elif err is not None: inkex.errormsg(err) def create_pathops(top_path, id_list, ink_verb): """Create the list of commands to execute for a single pathop.""" # build list with command line arguments cmdlist = [] for node_id in id_list: cmdlist.append("--select=" + top_path) cmdlist.append("--verb=EditDuplicate") cmdlist.append("--select=" + node_id) cmdlist.append("--verb=" + ink_verb) cmdlist.append("--verb=EditDeselect") return cmdlist def run_pathops(svgfile, cmds, max_ops, dry_run=False): """Run the pathops in chunks of max_ops commands.""" count = 0 chunk = cmds[:max_ops] cmds = cmds[max_ops:] while len(chunk) > 0: # build list with command line arguments count += 1 cmdlist = [] cmdlist.append("inkscape") for cmd in chunk: cmdlist += cmd cmdlist.append("--verb=FileSave") cmdlist.append("--verb=FileQuit") cmdlist.append("-f") cmdlist.append(svgfile) # process command list if dry_run: inkex.debug("\n# Processing {}. chunk ".format(count) + "with {} objects ...".format(len(chunk))) inkex.debug(cmdlist) else: run(cmdlist) chunk = cmds[:max_ops] cmds = cmds[max_ops:] return count def cleanup(tempfile): """Clean up tempfile.""" try: os.remove(tempfile) except Exception: # pylint: disable=broad-except pass # ----- PathOps() class, methods class PathOps(inkex.Effect): """Effect-based class to apply Inkscape path operations.""" def __init__(self): """Init base class.""" inkex.Effect.__init__(self) # options self.OptionParser.add_option("--ink_verb", action="store", type="string", dest="ink_verb", default="SelectionDiff", help="Inkscape verb for path op") self.OptionParser.add_option("--max_ops", action="store", type="int", dest="max_ops", default=500, help="Max ops per external run") self.OptionParser.add_option("--recursive_sel", action="store", type="inkbool", dest="recursive_sel", default=True, help="Recurse beyond one group level") self.OptionParser.add_option("--keep_top", action="store", type="inkbool", dest="keep_top", default=True, help="Keep top element when done") self.OptionParser.add_option("--default_stroke", action="store", type="string", dest="default_stroke", default="#000000", help="Default stroke color") self.OptionParser.add_option("--default_stroke_width", action="store", type="string", dest="default_stroke_width", default="1px", help="Default stroke width") self.OptionParser.add_option("--dry_run", action="store", type="inkbool", dest="dry_run", default=False, help="Dry-run without exec") def update_attrib(self, node, prop, val): """Update checked property.""" if not self.options.dry_run: node.attrib[prop] = val def check_props(self, node): """Check properties and modify as needed based on options.""" if self.options.ink_verb == 'SelectionCutPath': sdict = simplestyle.parseStyle(node.get('style')) fill_color = sdict.get('fill', '') if fill_color == 'none' or fill_color.startswith('url('): fill_color = None stroke_color = fill_color or self.options.default_stroke stroke_width = self.unittouu(self.options.default_stroke_width) prop = 'stroke' if prop not in sdict or sdict[prop] == 'none': sdict[prop] = stroke_color if prop in node.attrib and node.attrib[prop] == 'none': self.update_attrib(node, prop, stroke_color) prop = 'stroke-width' if prop not in sdict: sdict[prop] = stroke_width self.update_attrib(node, 'style', simplestyle.formatStyle(sdict)) def recurse_selection(self, node, id_list, level=0, current=0): """Recursively process selection, add checked elements to id list.""" current += 1 if not level or current <= level: if is_group(node): for child in node: id_list = self.recurse_selection( child, id_list, level, current) if does_pathops(node): self.check_props(node) id_list.append(node.get('id')) return id_list def get_selected_ids(self): """Return a list of valid ids for inkscape path operations.""" id_list = [] if not len(self.selected): pass else: # level = 0: unlimited recursion into groups # level = 1: process top-level groups only level = 0 if self.options.recursive_sel else 1 for node in self.selected.values(): self.recurse_selection(node, id_list, level) if len(id_list) < 2: inkex.errormsg("This extension requires at least 2 elements " "of type path, shape or text. " "The elements can be part of selected groups, " "or directly selected.") return None else: return id_list def get_sorted_ids(self): """Return id of top-most object, and a list with z-sorted ids.""" top_path = None sorted_ids = None id_list = self.get_selected_ids() if id_list is not None: sorted_ids = list(z_iter(self.document.getroot(), id_list)) top_path = sorted_ids.pop() return (top_path, sorted_ids) def loop_pathops(self, top_path, other_paths): """Loop through selected items and run external command(s).""" # init variables max_ops = self.options.max_ops or 500 ink_verb = self.options.ink_verb or "SelectionDiff" full_stack = False if ink_verb == "Trim": ink_verb = "SelectionDiff" full_stack = True dry_run = self.options.dry_run tempfile = os.path.splitext(self.svg_file)[0] + "-pathops.svg" # prepare if dry_run: inkex.debug("# Top object id: {}".format(top_path)) inkex.debug("# Other objects total: {}".format(len(other_paths))) else: with open(tempfile, 'wb') as copycat: self.document.write(copycat) # create a list of commands to execute cmds = [] while len(other_paths) > 0: cmds.append(create_pathops(top_path, other_paths, ink_verb)) if full_stack: # now we make the second path the new top and perform the # same operation again on it and the remaining paths top_path = other_paths.pop() else: break # run the commands, if necessary in chunks of up to max_ops at a time count = run_pathops(tempfile, cmds, max_ops, dry_run) # finish up if dry_run: inkex.debug("\n# {} chunks processed, ".format(count) + "with {} total objects.".format(len(other_paths))) else: # replace current document with content of temp copy xmlparser = inkex.etree.XMLParser(huge_tree=True) self.document = inkex.etree.parse(tempfile, parser=xmlparser) # optionally delete top-most element when done if not self.options.keep_top: top_node = self.getElementById(top_path) if top_node is not None: top_node.getparent().remove(top_node) # purge missing tagrefs (see below) self.update_tagrefs() # clean up cleanup(tempfile) def effect(self): """Main entry point to process current document.""" if self.has_tagrefs(): # unsafe to use with extensions ... inkex.errormsg("This document uses Inkscape selection sets. " "Modifying the content with a PathOps extension " "may cause Inkscape to crash on reload or close. " "Please delete the selection sets, " "save the document under a new name and " "try again in a new Inkscape session.") else: # process selection top_path, other_paths = self.get_sorted_ids() if top_path is None or other_paths is None: return else: self.loop_pathops(top_path, other_paths) # ----- workaround to avoid crash on quit # If selection set tagrefs have been deleted as a result of the # extension's modifications of the drawing content, inkscape will # crash when closing the document window later on unless the tagrefs # are checked and cleaned up manually by the extension script. # NOTE: crash on reload in the main process (after the extension has # finished) still happens if Selection Sets dialog was actually # opened and used in the current session ... the extension could # create fake (invisible) objects which reuse the ids? # No, fake placeholder elements do not prevent the crash on reload # if the dialog was opened before. # TODO: these checks (and the purging of obsolete tagrefs) probably # should be applied in Effect() itself, instead of relying on # workarounds in derived classes that modify drawing content. @staticmethod def get_tagrefs(node): """Find tagrefs in node, return list.""" inkscape_tagrefs = [] try: inkscape_tagrefs = node.findall( "inkscape:tag/inkscape:tagref", namespaces=inkex.NSS) except TypeError: # fallback for lxml < 2.3.0 path = 'inkscape:tag/inkscape:tagref' inkscape_tagrefs = node.xpath(path, namespaces=inkex.NSS) return inkscape_tagrefs def has_tagrefs(self): """Check whether document has selection sets with tagrefs.""" defs = get_defs(self.document.getroot()) return True if len(self.get_tagrefs(defs)) else False def update_tagrefs(self, mode='purge'): """Check tagrefs for deleted objects.""" defs = get_defs(self.document.getroot()) inkscape_tagrefs = self.get_tagrefs(defs) if len(inkscape_tagrefs): for tagref in inkscape_tagrefs: href = tagref.get(inkex.addNS('href', 'xlink'))[1:] if self.getElementById(href) is None: if mode == 'purge': tagref.getparent().remove(tagref) elif mode == 'placeholder': temp = inkex.etree.Element(inkex.addNS('path', 'svg')) temp.set('id', href) temp.set('d', 'M 0,0 Z') self.document.getroot().append(temp) # ----- workaround to fix Effect() performance with large selections def collect_ids(self, doc=None): """Iterate all elements, build id dicts (doc_ids, selected).""" doc = self.document if doc is None else doc id_list = list(self.options.ids) for node in doc.getroot().iter(tag=inkex.etree.Element): if 'id' in node.attrib: node_id = node.get('id') self.doc_ids[node_id] = 1 if node_id in id_list: self.selected[node_id] = node id_list.remove(node_id) def getselected(self): """Overload Effect() method.""" self.collect_ids() def getdocids(self): """Overload Effect() method.""" pass if __name__ == '__main__': ME = PathOps() ME.affect() # vim: et shiftwidth=4 tabstop=8 softtabstop=4 fileencoding=utf-8 textwidth=79