480 lines
18 KiB
Python
480 lines
18 KiB
Python
#!/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 <marenhachmannATyahoo.com>
|
|
(refactoring, extend to multibool)
|
|
2017 su_v <suv-sf@users.sf.net>
|
|
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 <defs> 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
|