#!/usr/bin/env python3 # coding=utf-8 """ Copyright (C) 2021 Thomas Maziere Largely and mostly inspired by inx-pathops (https://gitlab.com/moini_ink/inx-pathops/) 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. """ """ incadiff Apply successive difference operations on superimposed paths. Useful for plotter addicts. """ import os from shutil import copy2 import time from lxml import etree import inkex import inkex.command __version__ = '0.2' # 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 def get_inkscape_version(): ink = inkex.command.INKSCAPE_EXECUTABLE_NAME try: # needed prior to 1.1 ink_version = inkex.command.call(ink, '--version').decode("utf-8") except AttributeError: # needed starting from 1.1 ink_version = inkex.command.call(ink, '--version') pos = ink_version.find("Inkscape ") if pos != -1: pos += 9 else: return None v_num = ink_version[pos:pos+3] return(v_num) # ----- 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] class IncadiffExtension(inkex.Effect): def __init__(self): inkex.Effect.__init__(self) self.actions_list = [] def add_arguments(self, pars): pars.add_argument("--my_option", type=inkex.Boolean, help="An example option, put your options here") def get_selected_ids(self): """Return a list of valid ids for inkscape path operations.""" id_list = [] if len(self.svg.selected) == 0: pass else: level = 0 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 if len(id_list) > 64: inkex.errormsg("You should not select more than 64 shapes/paths, " + "and ideally you should apply this extension to small groups of objects.") return None else: return id_list def get_sorted_ids(self): """Return a list with z-sorted ids.""" 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)) return (sorted_ids) else: return def run_cmd(self, tempfile): #ink_version = get_inkscape_version() self.actions_list.append("FileSave") extra_param = "--batch-process" # if ink_version == "1.0": # self.actions_list.append("FileQuit") # extra_param = "--with-gui" actions = ";".join(self.actions_list) inkex.command.inkscape(tempfile, extra_param, actions=actions) def duplicate_and_diff(self, id_list, tempfile): # for each selected path, duplicate and diff for each path below nb_shapes = len(id_list) for i in range(0, nb_shapes): top_path = id_list[i] j = i while j > 0: j -= 1 self.actions_list.append("select-by-id:"+top_path) self.actions_list.append("EditDuplicate") self.actions_list.append("select-by-id:" + id_list[j]) self.actions_list.append("SelectionDiff") self.actions_list.append("EditDeselect") if nb_shapes > 20: self.run_cmd(tempfile) self.actions_list = [] if len(self.actions_list) > 0: self.run_cmd(tempfile) self.actions_list = [] def loop_diff(self): """Loop through selected items and run external command(s).""" tempfile = self.options.input_file + "-incadiff.svg" # prepare # 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 selected paths id_list = self.get_sorted_ids() if id_list is not None: self.duplicate_and_diff(id_list, tempfile) else: return # replace current document with content of temp copy file self.document = inkex.load_svg(tempfile) # update self.svg self.svg = self.document.getroot() # 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): if self.has_tagrefs(): # unsafe to use with extensions ... inkex.utils.errormsg("This document uses Inkscape selection sets. " + "Modifying the content with this 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: self.loop_diff() 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 len(inkscape_tagrefs) > 0 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) > 0: 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 if __name__ == '__main__': IncadiffExtension().run()