From 721dc2e361774834b84425a3d1ee7a7fea0bbb11 Mon Sep 17 00:00:00 2001 From: Moini <934908-Moini@users.noreply.gitlab.com> Date: Sun, 20 Oct 2019 00:06:16 +0200 Subject: [PATCH] Add su-v's pathops (multi-bool) extension. --- pathops.inx | 36 ++++ pathops.py | 452 +++++++++++++++++++++++++++++++++++++++ pathops_combine.inx | 26 +++ pathops_cutpath.inx | 26 +++ pathops_difference.inx | 26 +++ pathops_division.inx | 26 +++ pathops_exclusion.inx | 26 +++ pathops_intersection.inx | 26 +++ pathops_union.inx | 26 +++ 9 files changed, 670 insertions(+) create mode 100644 pathops.inx create mode 100644 pathops.py create mode 100644 pathops_combine.inx create mode 100644 pathops_cutpath.inx create mode 100644 pathops_difference.inx create mode 100644 pathops_division.inx create mode 100644 pathops_exclusion.inx create mode 100644 pathops_intersection.inx create mode 100644 pathops_union.inx diff --git a/pathops.inx b/pathops.inx new file mode 100644 index 00000000..d3a35512 --- /dev/null +++ b/pathops.inx @@ -0,0 +1,36 @@ + + + + <_name>PathOps Custom + su-v/org.inkscape.effect.path.ops + + pathops.py + inkex.py + + + + + + + + + + + 500 + true + true + false + + + + Apply Inkscape path operations to multiple objects. + all + + + + + diff --git a/pathops.py b/pathops.py new file mode 100644 index 00000000..ff839eb6 --- /dev/null +++ b/pathops.py @@ -0,0 +1,452 @@ +#!/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 run_pathops(svgfile, top_path, id_list, ink_verb, dry_run=False): + """Run path ops with top_path on a list of other object ids.""" + # build list with command line arguments + cmdlist = [] + cmdlist.append("inkscape") + 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") + cmdlist.append("--verb=FileSave") + cmdlist.append("--verb=FileQuit") + cmdlist.append("-f") + cmdlist.append(svgfile) + # process command list + if dry_run: + inkex.debug(cmdlist) + else: + run(cmdlist) + + +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 + count = 0 + max_ops = self.options.max_ops or 500 + ink_verb = self.options.ink_verb or "SelectionDiff" + 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) + # loop through sorted id list, process in chunks + for chunk in chunks(other_paths, max_ops): + count += 1 + if dry_run: + inkex.debug("\n# Processing {}. chunk ".format(count) + + "with {} objects ...".format(len(chunk))) + run_pathops(tempfile, top_path, chunk, ink_verb, 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 diff --git a/pathops_combine.inx b/pathops_combine.inx new file mode 100644 index 00000000..af1821d5 --- /dev/null +++ b/pathops_combine.inx @@ -0,0 +1,26 @@ + + + + <_name>7 Combine + su-v/org.inkscape.effect.path.combine + + pathops.py + inkex.py + + SelectionCombine + + + + Apply Inkscape 'Combine' path operation to multiple objects. + all + + + + + diff --git a/pathops_cutpath.inx b/pathops_cutpath.inx new file mode 100644 index 00000000..eb645e35 --- /dev/null +++ b/pathops_cutpath.inx @@ -0,0 +1,26 @@ + + + + <_name>6 Cut Path + su-v/org.inkscape.effect.path.cutpath + + pathops.py + inkex.py + + SelectionCutPath + + + + Apply Inkscape 'Cut Path' path operation to multiple objects. + all + + + + + diff --git a/pathops_difference.inx b/pathops_difference.inx new file mode 100644 index 00000000..cfb70c7c --- /dev/null +++ b/pathops_difference.inx @@ -0,0 +1,26 @@ + + + + <_name>2 Difference + su-v/org.inkscape.effect.path.difference + + pathops.py + inkex.py + + SelectionDiff + + + + Apply Inkscape 'Difference' path operation to multiple objects. + all + + + + + diff --git a/pathops_division.inx b/pathops_division.inx new file mode 100644 index 00000000..153f9bd3 --- /dev/null +++ b/pathops_division.inx @@ -0,0 +1,26 @@ + + + + <_name>5 Division + su-v/org.inkscape.effect.path.division + + pathops.py + inkex.py + + SelectionDivide + + + + Apply Inkscape 'Division' path operation to multiple objects. + all + + + + + diff --git a/pathops_exclusion.inx b/pathops_exclusion.inx new file mode 100644 index 00000000..61a13150 --- /dev/null +++ b/pathops_exclusion.inx @@ -0,0 +1,26 @@ + + + + <_name>4 Exclusion + su-v/org.inkscape.effect.path.exclusion + + pathops.py + inkex.py + + SelectionSymDiff + + + + Apply Inkscape 'Exclusion' path operation to multiple objects. + all + + + + + diff --git a/pathops_intersection.inx b/pathops_intersection.inx new file mode 100644 index 00000000..558a07b3 --- /dev/null +++ b/pathops_intersection.inx @@ -0,0 +1,26 @@ + + + + <_name>3 Intersection + su-v/org.inkscape.effect.path.intersect + + pathops.py + inkex.py + + SelectionIntersect + + + + Apply Inkscape 'Intersection' path operation to multiple objects. + all + + + + + diff --git a/pathops_union.inx b/pathops_union.inx new file mode 100644 index 00000000..03b6dae3 --- /dev/null +++ b/pathops_union.inx @@ -0,0 +1,26 @@ + + + + <_name>1 Union + su-v/org.inkscape.effect.path.union + + pathops.py + inkex.py + + SelectionUnion + + + + Apply Inkscape 'Union' path operation to multiple objects. + all + + + + + -- GitLab