From f57c056ddde6da01aa18bc84698e363b64875bad Mon Sep 17 00:00:00 2001 From: Mario Voigt Date: Sun, 4 Jul 2021 12:33:33 +0200 Subject: [PATCH] added batch task extension --- .../batch_task/BaseExtension.py | 195 ++++++++++++++++++ .../fablabchemnitz/batch_task/batch_task.inx | 177 ++++++++++++++++ .../fablabchemnitz/batch_task/batch_task.py | 152 ++++++++++++++ 3 files changed, 524 insertions(+) create mode 100644 extensions/fablabchemnitz/batch_task/BaseExtension.py create mode 100644 extensions/fablabchemnitz/batch_task/batch_task.inx create mode 100644 extensions/fablabchemnitz/batch_task/batch_task.py diff --git a/extensions/fablabchemnitz/batch_task/BaseExtension.py b/extensions/fablabchemnitz/batch_task/BaseExtension.py new file mode 100644 index 00000000..7e558f4d --- /dev/null +++ b/extensions/fablabchemnitz/batch_task/BaseExtension.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 + +# pylint: disable=too-many-ancestors + +# standard library +import os +import sys +import re +import argparse +from shutil import copy2 +# from subprocess import Popen, PIPE +# import time +# from lxml import etree + +# local library +import inkex +from inkex.command import inkscape +from inkex.elements import _selected as selection + +MIN_PYTHON_VERSION = (3, 6) # Mainly for f-strings +if (sys.version_info.major, sys.version_info.minor) < (3, 6): + inkex.Effect.msg(f"Python {MIN_PYTHON_VERSION[0]}.{MIN_PYTHON_VERSION[1]} or later required.") + sys.exit(1) + + +class BaseExtension(inkex.Effect): + """Custom class that makes creation of extensions easier. + + Users of this class need not worry about boilerplates, such as how to + call inkscape via shell, and the management of tempfiles. Useful functions + are also provided.""" + + def __init__(self, custom_effect, args_adder=None): + """Init base class. + + In a typical Inkscape extension that does not make use of BaseExtension, + the effect is determined by the "effect" method of the extension class. + This init function will take in a method, and run it in the "effect" method + together with the other boilerplate. + + This init method takes in a function under the custom_effect argument. + This function will handle the user's effects, minus the boilerplate. It + has to return a list[str] object, with each str being a verb that inkscape + can execute.""" + + inkex.Effect.__init__(self) + self.custom_effect = custom_effect + + self._msg = self.msg # The old msg function provided by inkex (only accepts strings) + def msg(*args, sep=' '): + """Improved msg method, similar to Python's print""" + self._msg(sep.join([str(arg) for arg in args])) + self.msg = msg + + if args_adder is not None: + args_adder(self.arg_parser) + self.args_adder = args_adder + + + + + def z_sort(self, alist): + """Return new list sorted in document order (depth-first traversal).""" + return list(self.z_iter(alist)) + + + def z_iter(self, alist): + """Return iterator over ids in document order (depth-first traversal).""" + id_list = list(alist) + count = len(id_list) + for element in self.document.getroot().iter(): + # element_id = element.get('id') + # if element_id is not None and element_id in id_list: + if element in alist: + id_list.remove(element) + yield element + count -= 1 + if not count: + return + + @staticmethod + def show(obj): + """Returns a str representation of object""" + def rep(obj): + if hasattr(obj, 'get_id'): + return f"{type(obj).__name__}({obj.get_id()})" + return f"{type(obj).__name__}" + + + if type(obj).__name__ == 'ElementList': + return ('ElementList(' + + ', '.join([rep(child) for child in obj.values()]) + + ')') + if isinstance(obj, list): + return '[' + ', '.join(rep(child) for child in obj) + ']' + + + return rep(obj) + + + def find(self, obj: any, xpath='/*') -> list: + """Returns a list of objects which satisfies XPath + + Args: + obj (any): Parent object to recurse into. Examples include root, selected, or a group. + xpath (str, optional): Defaults to '/*'. + + Returns: + list: [description] + """ + + BASIC_TAGS = ('circle', 'ellipse', 'line', 'polygon', 'polyline', 'rect', 'path', 'image', 'g') + SPECIAL_TAGS = { + 'l': "svg:g[@inkscape:groupmode='layer']", + 'p': 'svg:path' + } + + xpath = re.sub(r'((?<=/)(' + '|'.join(BASIC_TAGS) + r')\b)', r'svg:\1', xpath) + for k, v in SPECIAL_TAGS.items(): + xpath = re.sub('(?<=/)' + k + r'\b', v, xpath) + + xpath = re.sub(r'(?<=\[)(\d+):(\d+)(?=\])', r'position()>=\1 and position()<\2', xpath) + + if type(obj).__name__ != 'ElementList': + obj = [obj] + + output = [] + for child in obj: + matches = child.xpath(xpath, namespaces={ + 'svg': 'http://www.w3.org/2000/svg', + 'inkscape': 'http://www.inkscape.org/namespaces/inkscape'}) + for match in matches: + if type(match).__name__ not in ('Defs', 'NamedView', 'Metadata'): + output.append(match) + + return output + + + def effect(self): + """Main entry point to process current document. Not to be called externally.""" + + actions_list = self.custom_effect(self) + + if actions_list is None or actions_list == []: + self.msg("No actions received. Perhaps you are calling inkex object methods?") + elif isinstance(actions_list, list): + tempfile = self.options.input_file + "-BaseExtension.svg" + + # prepare + copy2(self.options.input_file, tempfile) + + #disabled because it leads to crash Inkscape: https://gitlab.com/inkscape/inkscape/-/issues/2487 + #actions_list.append("FileSave") + #actions_list.append("FileQuit") + #extra_param = "--with-gui" + + #workaround to fix it (we use export to tempfile instead processing and saving again) + actions_list.append("export-type:svg") + actions_list.append("export-filename:{}".format(tempfile)) + actions_list.append("export-do") + extra_param = "--batch-process" + + actions = ";".join(actions_list) + inkscape(tempfile, extra_param, actions=actions) + + + # finish up + # replace current document with content of temp copy file + self.document = inkex.load_svg(tempfile) + # update self.svg + self.svg = self.document.getroot() + + + # Clean up tempfile + try: + os.remove(tempfile) + except Exception: # pylint: disable=broad-except + pass + + def call(self, child, ext_options): + """Used to call an extension from another extension""" + + old_options = self.options + + parser = argparse.ArgumentParser() + child.args_adder(parser) + self.options = parser.parse_args([]) + + for k, v in ext_options.items(): + setattr(self.options, k, v) + + output = child.custom_effect(self) + self.options = old_options + + return output diff --git a/extensions/fablabchemnitz/batch_task/batch_task.inx b/extensions/fablabchemnitz/batch_task/batch_task.inx new file mode 100644 index 00000000..8453f35f --- /dev/null +++ b/extensions/fablabchemnitz/batch_task/batch_task.inx @@ -0,0 +1,177 @@ + + + Batch Task + fablabchemnitz.de.batch_task + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + true + + all + + + + + + + + + \ No newline at end of file diff --git a/extensions/fablabchemnitz/batch_task/batch_task.py b/extensions/fablabchemnitz/batch_task/batch_task.py new file mode 100644 index 00000000..e3cbc3ce --- /dev/null +++ b/extensions/fablabchemnitz/batch_task/batch_task.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +import os +import sys +import re +import subprocess + +from BaseExtension import BaseExtension + +# For linting purposes +from argparse import ArgumentParser + +"""If syntax error occurs here, change inkscape interpreter to python3""" + +"""I have yet to find a way for an extension to call another extension with parameters, +without GUI. This extension can be run as part of a standalone extension (using BaseExtension) +or imported for use by another extension. This workaround is done via the 'option' arg in +the 'custom_effect' function""" + + +def custom_effect(self: BaseExtension): + """Note: The init of the BaseExtension class will set its 'custom_effect' attr + to this function. Hence, the self arg is of type BaseExtension.""" + + + selected = self.svg.selected + root = self.document.getroot() + actions_list = [] + + proc = subprocess.run("inkscape --verb-list | grep -oP '^.+?(?=:)'", + shell=True, capture_output=True) + valid_actions_and_verbs = proc.stdout.decode().splitlines() + + proc = subprocess.run("inkscape --action-list | grep -oP '^.+?(?= *:)'", + shell=True, capture_output=True) + valid_actions_and_verbs += proc.stdout.decode().splitlines() + + + self.options.dry_run = self.options.dry_run == 'true' + + def verify_action(action): + if ':' in action: + action = action.split(':')[0] + if action not in valid_actions_and_verbs: + raise ValueError(action) + + def select_do_individually(objs, actions): + for obj in objs: + actions_list.append("EditDeselect") + actions_list.append("select-by-id:" + obj.get_id()) + if isinstance(actions, str): + actions = [actions] + for action in actions: + verify_action(action) + actions_list.append(action) + + def select_do_on_all(objs, actions): + for obj in objs: + actions_list.append("select-by-id:" + obj.get_id()) + + if isinstance(actions, str): + actions = [actions] + for action in actions: + verify_action(action) + actions_list.append(action) + effects = [] + try: + if self.options.tab_effect is None: + if self.options.effects is not None: + self.options.tab_effect = 'Multi' + elif self.options.effect1 is not None: + self.options.tab_effect = 'Simple' + + elif self.options.tab_effect in ('Preset', 'Simple'): + for attr in ('effect_' + self.options.tab_effect.lower() + str(i) for i in range(1, 4)): + e = getattr(self.options, attr) + if e != None: + effects += [e.strip()] + if effects == []: + raise ValueError + elif self.options.tab_effect == 'Multi': + if self.options.effects is None: + raise ValueError + for line in self.options.effects.split('\\n'): + effects += [e.strip() for e in line.split(';') if e != ''] + except ValueError: + self.msg("No effects inputted! Quitting...") + sys.exit(0) + + + if self.options.target == 'root': + objects = self.find(root, '/svg:svg' + self.options.xpath) + elif self.options.target == 'selected': + objects = self.find(selected, self.options.xpath) + if objects == []: + self.msg(f"No objects satisfies XPath: '{self.options.xpath}'.") + self.msg("Root:", self.show(root)) + self.msg("Selected:", self.show(selected)) + sys.exit(0) + + + try: + if self.options.mode == 'all': + select_do_on_all(objects, effects) + elif self.options.mode == 'indiv': + select_do_individually(objects, effects) + except ValueError as e: + self.msg(f"'{e.args[0]}' is not a valid action or verb in inkscape.") + sys.exit(1) + + if self.options.dry_run: + self.msg(f"{'DRY RUN':=^40}") + self.msg("Root:", self.show(self.find(root, '/*'))) + self.msg("Selected:", self.show(selected)) + self.msg() + self.msg("XPath:", self.show(objects)) + self.msg() + self.msg("Actions:", actions_list) + sys.exit(0) + return actions_list + + +def args_adder(arg_parser: ArgumentParser): + + arg_parser.add_argument("--target", default='root', help="Object to apply xpath find on") + arg_parser.add_argument("--xpath", default='/*', help="For selection of objects") + arg_parser.add_argument("--tab_main", default=None) + arg_parser.add_argument("--Simple", default=None) + arg_parser.add_argument("--Multi", default=None) + arg_parser.add_argument("--mode", default="all", help="Mode to apply effects on objects") + arg_parser.add_argument("--tab_effect", default=None) + for arg in (*(x + str(y) for x in ('effect_preset', 'effect_simple') for y in range(1, 4)), 'effects'): + arg_parser.add_argument(f"--{arg}", default=None, help="Inkscape verb for path op") + arg_parser.add_argument("--dry_run", default='false') + arg_parser.add_argument("--null_notebook", default='false') + + #import inkex + #for key, value in arg_parser.parse_args()._get_kwargs(): + # if value is not None: + # inkex.utils.debug("{}={}".format(key, value)) + +BatchTask = BaseExtension(custom_effect, args_adder=args_adder) + +if __name__ == '__main__': + BatchTask.run() + + + + +# Namespace(Multi='SelectionDiff', Simple='SelectionDiff', dry_run='false', effect1='SelectionBreakApart', effect2=None, effect3=None, effects=None, ids=['image25'], input_file='/tmp/ink_ext_XXXXXX.svgIDCKU0', mode='all', null='null', output=<_io.BufferedWriter name=''>, selected_nodes=[], tab_effect='Simple', tab_main='Options', target='root', xpath='/*') + + +# Namespace(Multi=None, Simple=None, dry_run='false', effect1='SelectionDelete', effect2=None, effect3=None, effects=None, mode='all', null='false', tab_effect=None, tab_main=None, target='root', xpath='/*')