From bb0028c2558468dd626749cec0b65ff3bfc30c3c Mon Sep 17 00:00:00 2001 From: Mario Voigt Date: Wed, 19 Aug 2020 12:58:32 +0200 Subject: [PATCH] Added inx-pathops from Maren :) --- extensions/fablabchemnitz_pathops.inx | 30 ++ extensions/fablabchemnitz_pathops.py | 390 ++++++++++++++++++ extensions/fablabchemnitz_pathops_combine.inx | 18 + extensions/fablabchemnitz_pathops_cutpath.inx | 18 + .../fablabchemnitz_pathops_difference.inx | 18 + .../fablabchemnitz_pathops_division.inx | 18 + .../fablabchemnitz_pathops_exclusion.inx | 18 + .../fablabchemnitz_pathops_intersection.inx | 18 + extensions/fablabchemnitz_pathops_union.inx | 18 + 9 files changed, 546 insertions(+) create mode 100644 extensions/fablabchemnitz_pathops.inx create mode 100644 extensions/fablabchemnitz_pathops.py create mode 100644 extensions/fablabchemnitz_pathops_combine.inx create mode 100644 extensions/fablabchemnitz_pathops_cutpath.inx create mode 100644 extensions/fablabchemnitz_pathops_difference.inx create mode 100644 extensions/fablabchemnitz_pathops_division.inx create mode 100644 extensions/fablabchemnitz_pathops_exclusion.inx create mode 100644 extensions/fablabchemnitz_pathops_intersection.inx create mode 100644 extensions/fablabchemnitz_pathops_union.inx diff --git a/extensions/fablabchemnitz_pathops.inx b/extensions/fablabchemnitz_pathops.inx new file mode 100644 index 00000000..7569c3fb --- /dev/null +++ b/extensions/fablabchemnitz_pathops.inx @@ -0,0 +1,30 @@ + + + PathOps Custom + fablabchemnitz.de.pathops + + + + + + + + + + 500 + true + true + false + + + Apply Inkscape path operations to multiple objects. + all + + + \ No newline at end of file diff --git a/extensions/fablabchemnitz_pathops.py b/extensions/fablabchemnitz_pathops.py new file mode 100644 index 00000000..4fe8aae1 --- /dev/null +++ b/extensions/fablabchemnitz_pathops.py @@ -0,0 +1,390 @@ +#!/usr/bin/env python3 +""" +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. + 2020 Maren Hachmann + Update to make it work with Inkscape 1.0's new inx scheme, extensions API and command line API. + +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 shutil import copy2 +from subprocess import Popen, PIPE +import time +from lxml import etree + +# local library +import inkex +from inkex.command import inkscape + +__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 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 recurse_selection(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 = recurse_selection(child, id_list, level, current) + if does_pathops(node): + id_list.append(node.get('id')) + return id_list + + +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.utils.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) + + +# ----- 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.arg_parser.add_argument("--ink_verb", default="SelectionDiff", help="Inkscape verb for path op") + self.arg_parser.add_argument("--max_ops", type=int, default=500, help="Max ops per external run") + self.arg_parser.add_argument("--recursive_sel", type=inkex.Boolean, help="Recurse beyond one group level") + self.arg_parser.add_argument("--keep_top", type=inkex.Boolean, help="Keep top element when done") + self.arg_parser.add_argument("--dry_run", type=inkex.Boolean, default=False, help="Dry-run without exec") + + def get_selected_ids(self): + """Return a list of valid ids for inkscape path operations.""" + id_list = [] + if not len(self.svg.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.svg.selected.values(): + 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 run_pathops(self, 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 + actions_list = [] + for node_id in id_list: + actions_list.append("select-by-id:" + top_path) + actions_list.append("EditDuplicate") + actions_list.append("select-by-id:" + node_id) + actions_list.append(ink_verb) + actions_list.append("EditDeselect") + actions_list.append("FileSave") + actions_list.append("FileQuit") + actions = ";".join(actions_list) + # process command list + if dry_run: + inkex.utils.debug(" ".join(["inkscape", "--with-gui", "--actions=" + "\"" + actions + "\"", svgfile])) + else: + inkscape(svgfile, "--with-gui", actions=actions) + + 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 = self.options.input_file + "-pathops.svg" + # prepare + if dry_run: + inkex.utils.debug("# Top object id: {}".format(top_path)) + inkex.utils.debug("# Other objects total: {}".format(len(other_paths))) + else: + # we need to do this because command line Inkscape with gui + # gives lots of info dialogs when the file extension isn't 'svg' + # so the inkscape() call cannot open the file without user + # interaction, and fails in the end when trying to save + copy2(self.options.input_file, tempfile) + # loop through sorted id list, process in chunks + for chunk in chunks(other_paths, max_ops): + count += 1 + if dry_run: + inkex.utils.debug("\n# Processing {}. chunk ".format(count) + + "with {} objects ...".format(len(chunk))) + self.run_pathops(tempfile, top_path, chunk, ink_verb, dry_run) + # finish up + if dry_run: + inkex.utils.debug("\n# {} chunks processed, ".format(count) + + "with {} total objects.".format(len(other_paths))) + else: + # replace current document with content of temp copy file + self.document = inkex.load_svg(tempfile) + # update self.svg + self.svg = self.document.getroot() + + # optionally delete top-most element when done + if not self.options.keep_top: + top_node = self.svg.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 + self.cleanup(tempfile) + + def cleanup(self, tempfile): + """Clean up tempfile.""" + try: + os.remove(tempfile) + except Exception: # pylint: disable=broad-except + pass + + 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. + + def has_tagrefs(self): + """Check whether document has selection sets with tagrefs.""" + defs = get_defs(self.document.getroot()) + inkscape_tagrefs = defs.findall( + "inkscape:tag/inkscape:tagref", namespaces=inkex.NSS) + return True if len(inkscape_tagrefs) else False + + def update_tagrefs(self, mode='purge'): + """Check tagrefs for deleted objects.""" + defs = get_defs(self.document.getroot()) + inkscape_tagrefs = defs.findall( + "inkscape:tag/inkscape:tagref", namespaces=inkex.NSS) + if len(inkscape_tagrefs): + for tagref in inkscape_tagrefs: + href = tagref.get(inkex.addNS('href', 'xlink'))[1:] + if self.svg.getElementById(href) is None: + if mode == 'purge': + tagref.getparent().remove(tagref) + elif mode == 'placeholder': + temp = 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=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.svg.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 + +PathOps().run() \ No newline at end of file diff --git a/extensions/fablabchemnitz_pathops_combine.inx b/extensions/fablabchemnitz_pathops_combine.inx new file mode 100644 index 00000000..3714accf --- /dev/null +++ b/extensions/fablabchemnitz_pathops_combine.inx @@ -0,0 +1,18 @@ + + + Combine + fablabchemnitz.de.pathops_combine + SelectionCombine + + + Apply Inkscape 'Combine' path operation to multiple objects. + all + + + \ No newline at end of file diff --git a/extensions/fablabchemnitz_pathops_cutpath.inx b/extensions/fablabchemnitz_pathops_cutpath.inx new file mode 100644 index 00000000..6ef1bfde --- /dev/null +++ b/extensions/fablabchemnitz_pathops_cutpath.inx @@ -0,0 +1,18 @@ + + + Cut Path + fablabchemnitz.de.pathops_cutpath + SelectionCutPath + + + Apply Inkscape 'Cut Path' path operation to multiple objects. + all + + + \ No newline at end of file diff --git a/extensions/fablabchemnitz_pathops_difference.inx b/extensions/fablabchemnitz_pathops_difference.inx new file mode 100644 index 00000000..67acafc3 --- /dev/null +++ b/extensions/fablabchemnitz_pathops_difference.inx @@ -0,0 +1,18 @@ + + + Difference + fablabchemnitz.de.pathops_difference + SelectionDiff + + + Apply Inkscape 'Difference' path operation to multiple objects. + all + + + \ No newline at end of file diff --git a/extensions/fablabchemnitz_pathops_division.inx b/extensions/fablabchemnitz_pathops_division.inx new file mode 100644 index 00000000..ee1e26ae --- /dev/null +++ b/extensions/fablabchemnitz_pathops_division.inx @@ -0,0 +1,18 @@ + + + Division + fablabchemnitz.de.pathops_division + SelectionDivide + + + Apply Inkscape 'Division' path operation to multiple objects. + all + + + \ No newline at end of file diff --git a/extensions/fablabchemnitz_pathops_exclusion.inx b/extensions/fablabchemnitz_pathops_exclusion.inx new file mode 100644 index 00000000..63729454 --- /dev/null +++ b/extensions/fablabchemnitz_pathops_exclusion.inx @@ -0,0 +1,18 @@ + + + Exclusion + fablabchemnitz.de.pathops_exclusion + SelectionSymDiff + + + Apply Inkscape 'Exclusion' path operation to multiple objects. + all + + + \ No newline at end of file diff --git a/extensions/fablabchemnitz_pathops_intersection.inx b/extensions/fablabchemnitz_pathops_intersection.inx new file mode 100644 index 00000000..d4d57a6c --- /dev/null +++ b/extensions/fablabchemnitz_pathops_intersection.inx @@ -0,0 +1,18 @@ + + + Intersection + fablabchemnitz.de.pathops_intersect + SelectionIntersect + + + Apply Inkscape 'Intersection' path operation to multiple objects. + all + + + \ No newline at end of file diff --git a/extensions/fablabchemnitz_pathops_union.inx b/extensions/fablabchemnitz_pathops_union.inx new file mode 100644 index 00000000..662c591f --- /dev/null +++ b/extensions/fablabchemnitz_pathops_union.inx @@ -0,0 +1,18 @@ + + + Union + fablabchemnitz.depath_union + SelectionUnion + + + Apply Inkscape 'Union' path operation to multiple objects. + all + + + \ No newline at end of file