Advertisement
yclee126

Inkscape PathOps Flatten mod

Jan 26th, 2023
877
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
Python 19.76 KB | None | 0 0
  1. #!/usr/bin/env python
  2. """pathops.py - Inkscape extension to apply multiple path operations
  3.  
  4. ###
  5. Flatten modification by yclee126 - select "difference" in the toolbar.
  6. Code version: 6
  7. Replace "pathops.py" contents with this file.
  8. ###
  9.  
  10. This extension takes a selection of path and a group of paths, or several
  11. paths, and applies a path operation with the top-most path in the z-order, and
  12. each selected path or each child of a selected group underneath.
  13.  
  14. Copyright (C) 2014  Ryan Lerch (multiple difference)
  15.              2016  Maren Hachmann <marenhachmannATyahoo.com>
  16.                    (refactoring, extend to multibool)
  17.              2017  su_v <suv-sf@users.sf.net>
  18.                    Rewrite to support large selections (process in chunks), to
  19.                    improve performance (support groups, z-sort ids with python
  20.                    instead of external query), and to extend GUI options.
  21.              2020-2021  Maren Hachmann <marenhachmann@yahoo.com>
  22.                    Update to make it work with Inkscape 1.0's new inx scheme,
  23.                    extensions API and command line API.
  24.                    Update to make it work with Inkscape 1.1's command line.
  25.  
  26. This program is free software; you can redistribute it and/or modify
  27. it under the terms of the GNU General Public License as published by
  28. the Free Software Foundation; either version 2 of the License, or
  29. (at your option) any later version.
  30.  
  31. This program is distributed in the hope that it will be useful,
  32. but WITHOUT ANY WARRANTY; without even the implied warranty of
  33. MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
  34. GNU General Public License for more details.
  35.  
  36. You should have received a copy of the GNU General Public License along
  37. with this program; if not, write to the Free Software Foundation, Inc.,
  38. 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
  39.  
  40. """
  41. # pylint: disable=too-many-ancestors
  42.  
  43. # standard library
  44. import os
  45. from shutil import copy2
  46. from subprocess import Popen, PIPE
  47. import time
  48. from lxml import etree
  49.  
  50. # local library
  51. import inkex
  52. import inkex.command
  53.  
  54. __version__ = '1.3'
  55. # supported Inkscape versions: "1.0", "1.1", "1.2"
  56.  
  57.  
  58. # Global "constants"
  59. SVG_SHAPES = ('rect', 'circle', 'ellipse', 'line', 'polyline', 'polygon')
  60.  
  61.  
  62.  
  63. # ----- general helper functions
  64.  
  65. def timed(f):
  66.     """Minimalistic timer for functions."""
  67.     # pylint: disable=invalid-name
  68.     start = time.time()
  69.     ret = f()
  70.     elapsed = time.time() - start
  71.     return ret, elapsed
  72.  
  73. def get_inkscape_version():
  74.     ink = inkex.command.INKSCAPE_EXECUTABLE_NAME
  75.     try: # needed prior to 1.1
  76.         ink_version = inkex.command.call(ink, '--version').decode("utf-8")
  77.     except AttributeError: # needed starting from 1.1
  78.         ink_version = inkex.command.call(ink, '--version')
  79.  
  80.     pos = ink_version.find("Inkscape ")
  81.     if pos != -1:
  82.         pos += 9
  83.     else:
  84.         return None
  85.     v_num = ink_version[pos:pos+3]
  86.     return(v_num)
  87.  
  88. # ----- SVG element helper functions
  89.  
  90. def get_defs(node):
  91.     """Find <defs> in children of *node*, return first one found."""
  92.     path = '/svg:svg//svg:defs'
  93.     try:
  94.         return node.xpath(path, namespaces=inkex.NSS)[0]
  95.     except IndexError:
  96.         return etree.SubElement(node, inkex.addNS('defs', 'svg'))
  97.  
  98.  
  99. def is_group(node):
  100.     """Check node for group tag."""
  101.     return node.tag == inkex.addNS('g', 'svg')
  102.  
  103.  
  104. def is_path(node):
  105.     """Check node for path tag."""
  106.     return node.tag == inkex.addNS('path', 'svg')
  107.  
  108.  
  109. def is_basic_shape(node):
  110.     """Check node for SVG basic shape tag."""
  111.     return node.tag in (inkex.addNS(tag, 'svg') for tag in SVG_SHAPES)
  112.  
  113.  
  114. def is_custom_shape(node):
  115.     """Check node for Inkscape custom shape type."""
  116.     return inkex.addNS('type', 'sodipodi') in node.attrib
  117.  
  118.  
  119. def is_shape(node):
  120.     """Check node for SVG basic shape tag or Inkscape custom shape type."""
  121.     return is_basic_shape(node) or is_custom_shape(node)
  122.  
  123.  
  124. def has_path_effect(node):
  125.     """Check node for Inkscape path-effect attribute."""
  126.     return inkex.addNS('path-effect', 'inkscape') in node.attrib
  127.  
  128.  
  129. def is_modifiable_path(node):
  130.     """Check node for editable path data."""
  131.     return is_path(node) and not (has_path_effect(node) or
  132.                                   is_custom_shape(node))
  133.  
  134.  
  135. def is_image(node):
  136.     """Check node for image tag."""
  137.     return node.tag == inkex.addNS('image', 'svg')
  138.  
  139.  
  140. def is_text(node):
  141.     """Check node for text tag."""
  142.     return node.tag == inkex.addNS('text', 'svg')
  143.  
  144.  
  145. def does_pathops(node):
  146.     """Check whether node is supported by Inkscape path operations."""
  147.     return (is_path(node) or
  148.             is_shape(node) or
  149.             is_text(node))
  150.  
  151.  
  152. # ----- list processing helper functions
  153.  
  154. def recurse_selection(node, id_list, level=0, current=0):
  155.     """Recursively process selection, add checked elements to id list."""
  156.     current += 1
  157.     if not level or current <= level:
  158.         if is_group(node):
  159.             for child in node:
  160.                 id_list = recurse_selection(child, id_list, level, current)
  161.     if does_pathops(node):
  162.         id_list.append(node.get('id'))
  163.     return id_list
  164.  
  165.  
  166. def z_sort(node, alist):
  167.     """Return new list sorted in document order (depth-first traversal)."""
  168.     ordered = []
  169.     id_list = list(alist)
  170.     count = len(id_list)
  171.     for element in node.iter():
  172.         element_id = element.get('id')
  173.         if element_id is not None and element_id in id_list:
  174.             id_list.remove(element_id)
  175.             ordered.append(element_id)
  176.             count -= 1
  177.             if not count:
  178.                 break
  179.     return ordered
  180.  
  181.  
  182. def z_iter(node, alist):
  183.     """Return iterator over ids in document order (depth-first traversal)."""
  184.     id_list = list(alist)
  185.     for element in node.iter():
  186.         element_id = element.get('id')
  187.         if element_id is not None and element_id in id_list:
  188.             id_list.remove(element_id)
  189.             yield element_id
  190.  
  191.  
  192.  
  193. # ----- process external command, files
  194.  
  195. # def run(cmd_format, stdin_str=None, verbose=False):
  196. #     """Run command"""
  197. #     if verbose:
  198. #         inkex.utils.debug(cmd_format)
  199. #     out = err = None
  200. #     myproc = Popen(cmd_format, shell=False,
  201. #                    stdin=PIPE, stdout=PIPE, stderr=PIPE)
  202. #     out, err = myproc.communicate(stdin_str)
  203. #     if myproc.returncode == 0:
  204. #         return out
  205. #     elif err is not None:
  206. #         inkex.errormsg(err)
  207.  
  208.  
  209. # ----- PathOps() class, methods
  210.  
  211. class PathOps(inkex.Effect):
  212.     """Effect-based class to apply Inkscape path operations."""
  213.  
  214.     def __init__(self):
  215.         """Init base class."""
  216.         inkex.Effect.__init__(self)
  217.  
  218.         # options
  219.         self.arg_parser.add_argument("--mode",
  220.                                      default="diff",
  221.                                      help="Type of path operation: [un|diff|inter|exclor|div|cut|comb]")
  222.         self.arg_parser.add_argument("--max_ops",
  223.                                      type=int,
  224.                                      default=300,
  225.                                      help="Max ops per external run")
  226.         self.arg_parser.add_argument("--recursive_sel",
  227.                                      type=inkex.Boolean,
  228.                                      help="Recurse beyond one group level")
  229.         self.arg_parser.add_argument("--keep_top",
  230.                                      type=inkex.Boolean,
  231.                                      help="Keep top element when done")
  232.         self.arg_parser.add_argument("--dry_run",
  233.                                      type=inkex.Boolean,
  234.                                      default=False,
  235.                                      help="Dry-run without exec")
  236.  
  237.     def get_selected_ids(self):
  238.         """Return a list of valid ids for inkscape path operations."""
  239.         id_list = []
  240.         if len(self.svg.selected) == 0:
  241.             pass
  242.         else:
  243.             # level = 0: unlimited recursion into groups
  244.             # level = 1: process top-level groups only
  245.             level = 0 if self.options.recursive_sel else 1
  246.             for node in self.svg.selected.values():
  247.                 recurse_selection(node, id_list, level)
  248.         if len(id_list) < 2:
  249.             inkex.errormsg("This extension requires at least 2 elements " +
  250.                            "of type path, shape or text. " +
  251.                            "The elements can be part of selected groups, " +
  252.                            "or directly selected.")
  253.             return None
  254.         else:
  255.             return id_list
  256.  
  257.     def get_sorted_ids(self):
  258.         """Return id of top-most object, and a list with z-sorted ids."""
  259.         top_path = None
  260.         sorted_ids = None
  261.         id_list = self.get_selected_ids()
  262.         if id_list is not None:
  263.             sorted_ids = list(z_iter(self.document.getroot(), id_list))
  264.             top_path = sorted_ids.pop()
  265.         return (top_path, sorted_ids)
  266.  
  267.     def run_actions(self, svgfile, inkversion, actions_list, dry_run=False):
  268.         """Run actions_list created by pathops_chunks"""
  269.         # assume it's saving
  270.         if inkversion == "1.0":
  271.             actions_list.append("FileQuit")
  272.             extra_param = "--with-gui"
  273.         elif inkversion == "1.1":
  274.             extra_param = "--batch-process"
  275.         else:
  276.             extra_param = ""
  277.  
  278.         actions = ";".join(actions_list)
  279.  
  280.         # process command list
  281.         if dry_run:
  282.             inkex.utils.debug(" ".join(["inkscape", extra_param, "--actions=" + "\"" + actions + "\"", svgfile, f"(using Inkscape {inkversion})"]))
  283.         else:
  284.             if extra_param != "":
  285.                 inkex.command.inkscape(svgfile, extra_param, actions=actions)
  286.             else:
  287.                 inkex.command.inkscape(svgfile, actions=actions)
  288.        
  289.    
  290.     def pathops_chunks(self, svgfile, top_path, id_list, mode, max_ops, inkversion, dry_run=False):
  291.         """Run path ops as an iterator"""
  292.         # build list with command line arguments
  293.         # Version-dependent. This one is for Inkscape 1.1 (else it crashes, see https://gitlab.com/inkscape/inbox/-/issues/4905)
  294.  
  295.         ACTIONS = {
  296.             "1.2":
  297.             {
  298.                 "dup": "duplicate",
  299.                 "un": "path-union",
  300.                 "diff": "path-difference",
  301.                 "inter": "path-intersection",
  302.                 "exclor": "path-exclusion",
  303.                 "div": "path-division",
  304.                 "cut": "path-cut",
  305.                 "comb": "path-combine",
  306.                 "desel": "select-clear",
  307.                 "save": f"export-filename:{svgfile};export-overwrite;export-do",
  308.             },
  309.             "1.1":
  310.             {
  311.                 "dup": "EditDuplicate",
  312.                 "un": "SelectionUnion",
  313.                 "diff": "SelectionDiff",
  314.                 "inter": "SelectionIntersect",
  315.                 "exclor": "SelectionSymDiff",
  316.                 "div": "SelectionDivide",
  317.                 "cut": "SelectionCutPath",
  318.                 "comb": "SelectionCombine",
  319.                 "desel": "EditDeselect",
  320.                 "save": "FileSave",
  321.             },
  322.             "1.0":
  323.             {
  324.                 "dup": "EditDuplicate",
  325.                 "un": "SelectionUnion",
  326.                 "diff": "SelectionDiff",
  327.                 "inter": "SelectionIntersect",
  328.                 "exclor": "SelectionSymDiff",
  329.                 "div": "SelectionDivide",
  330.                 "cut": "SelectionCutPath",
  331.                 "comb": "SelectionCombine",
  332.                 "desel": "EditDeselect",
  333.                 "save": "FileSave",
  334.             },
  335.         }
  336.  
  337.         actions_list = []
  338.         path_op_command = ACTIONS[inkversion][mode]
  339.         duplicate_command = ACTIONS[inkversion]['dup']
  340.         deselect_command = ACTIONS[inkversion]['desel']
  341.         save_command = ACTIONS[inkversion]['save']
  342.        
  343.         ### Flatten mod - select "difference" in the toolbar
  344.        
  345.         id_list = id_list + [top_path]
  346.         bbox_list = [self.svg.getElementById(id).bounding_box() for id in id_list]
  347.         ops_count = 0
  348.        
  349.         # from behind, select each path then difference all paths in front of it
  350.         for target_id_index in range(len(id_list)):
  351.             target_bbox = bbox_list[target_id_index]
  352.            
  353.             for upper_id_index in range(target_id_index+1, len(id_list)):
  354.                 # don't do operations if bbox don't overlap
  355.                 upper_bbox = bbox_list[upper_id_index]
  356.                 if target_bbox.right < upper_bbox.left or upper_bbox.right < target_bbox.left or target_bbox.bottom < upper_bbox.top or upper_bbox.bottom < target_bbox.top:
  357.                     continue
  358.                
  359.                 # append actions
  360.                 actions_list.append("select-by-id:" + id_list[upper_id_index])
  361.                 actions_list.append(duplicate_command)
  362.                 actions_list.append("select-by-id:" + id_list[target_id_index])
  363.                 actions_list.append(path_op_command)
  364.                 actions_list.append(deselect_command)
  365.                
  366.                 # yield to each max_ops
  367.                 ops_count += 1
  368.                 if ops_count % max_ops == 0:
  369.                     actions_list.append(save_command)
  370.                     yield actions_list
  371.                     actions_list = [] # it's frozen at yield so the list is safe to use outside until the next iteration
  372.        
  373.         # final iteration
  374.         actions_list.append(save_command)
  375.         yield actions_list
  376.        
  377.     def loop_pathops(self, top_path, other_paths):
  378.         """Loop through selected items and run external command(s)."""
  379.         # init variables
  380.         count = 0
  381.         max_ops = self.options.max_ops or 300
  382.         ink_verb = self.options.mode
  383.         dry_run = self.options.dry_run
  384.         tempfile = self.options.input_file + "-pathops.svg"
  385.         # prepare
  386.         if dry_run:
  387.             inkex.utils.debug("# Top object id: {}".format(top_path))
  388.             inkex.utils.debug("# Other objects total: {}".format(len(other_paths)))
  389.         else:
  390.             # we need to do this because command line Inkscape with gui
  391.             # gives lots of info dialogs when the file extension isn't 'svg'
  392.             # so the inkscape() call cannot open the file without user
  393.             # interaction, and fails in the end when trying to save
  394.             copy2(self.options.input_file, tempfile)
  395.        
  396.         # loop through sorted id list, process in chunks
  397.         inkversion = get_inkscape_version() ### this is such a pain, just do it on the outside
  398.        
  399.         ### the chunk is modified to be list of actions
  400.         for chunk in self.pathops_chunks(tempfile, top_path, other_paths, ink_verb, max_ops, inkversion, dry_run):
  401.             count += 1
  402.             if dry_run:
  403.                 inkex.utils.debug("\n# Processing {}. chunk ".format(count) +
  404.                                   "with {} objects ...".format(len(chunk)))
  405.             self.run_actions(tempfile, inkversion, chunk, dry_run)
  406.         # finish up
  407.         if dry_run:
  408.             inkex.utils.debug("\n# {} chunks processed, ".format(count) +
  409.                               "with {} total objects.".format(len(other_paths)))
  410.         else:
  411.             # replace current document with content of temp copy file
  412.             self.document = inkex.load_svg(tempfile)
  413.             # update self.svg
  414.             self.svg = self.document.getroot()
  415.  
  416.             # optionally delete top-most element when done
  417.             ### no, just don't delete it
  418.             if False and not self.options.keep_top:
  419.                 top_node = self.svg.getElementById(top_path)
  420.                 if top_node is not None:
  421.                     top_node.getparent().remove(top_node)
  422.             # purge missing tagrefs (see below)
  423.             self.update_tagrefs()
  424.             # clean up
  425.             self.cleanup(tempfile)
  426.  
  427.     def cleanup(self, tempfile):
  428.         """Clean up tempfile."""
  429.         try:
  430.             os.remove(tempfile)
  431.         except Exception:  # pylint: disable=broad-except
  432.             pass
  433.  
  434.     def effect(self):
  435.         """Main entry point to process current document."""
  436.         if self.has_tagrefs():
  437.             # unsafe to use with extensions ...
  438.             inkex.utils.errormsg("This document uses Inkscape selection sets. " +
  439.                            "Modifying the content with a PathOps extension " +
  440.                            "may cause Inkscape to crash on reload or close. " +
  441.                            "Please delete the selection sets, " +
  442.                            "save the document under a new name and " +
  443.                            "try again in a new Inkscape session.")
  444.         else:
  445.             # process selection
  446.             top_path, other_paths = self.get_sorted_ids()
  447.             if top_path is None or other_paths is None:
  448.                 return
  449.             else:
  450.                 self.loop_pathops(top_path, other_paths)
  451.  
  452.     # ----- workaround to avoid crash on quit
  453.  
  454.     # If selection set tagrefs have been deleted as a result of the
  455.     # extension's modifications of the drawing content, inkscape will
  456.     # crash when closing the document window later on unless the tagrefs
  457.     # are checked and cleaned up manually by the extension script.
  458.  
  459.     # NOTE: crash on reload in the main process (after the extension has
  460.     # finished) still happens if Selection Sets dialog was actually
  461.     # opened and used in the current session ... the extension could
  462.     # create fake (invisible) objects which reuse the ids?
  463.     # No, fake placeholder elements do not prevent the crash on reload
  464.     # if the dialog was opened before.
  465.  
  466.     # TODO: these checks (and the purging of obsolete tagrefs) probably
  467.     # should be applied in Effect() itself, instead of relying on
  468.     # workarounds in derived classes that modify drawing content.
  469.  
  470.     def has_tagrefs(self):
  471.         """Check whether document has selection sets with tagrefs."""
  472.         defs = get_defs(self.document.getroot())
  473.         inkscape_tagrefs = defs.findall(
  474.             "inkscape:tag/inkscape:tagref", namespaces=inkex.NSS)
  475.         return len(inkscape_tagrefs) > 0
  476.  
  477.     def update_tagrefs(self, mode='purge'):
  478.         """Check tagrefs for deleted objects."""
  479.         defs = get_defs(self.document.getroot())
  480.         inkscape_tagrefs = defs.findall(
  481.             "inkscape:tag/inkscape:tagref", namespaces=inkex.NSS)
  482.         if len(inkscape_tagrefs) > 0:
  483.             for tagref in inkscape_tagrefs:
  484.                 href = tagref.get(inkex.addNS('href', 'xlink'))[1:]
  485.                 if self.svg.getElementById(href) is None:
  486.                     if mode == 'purge':
  487.                         tagref.getparent().remove(tagref)
  488.                     elif mode == 'placeholder':
  489.                         temp = etree.Element(inkex.addNS('path', 'svg'))
  490.                         temp.set('id', href)
  491.                         temp.set('d', 'M 0,0 Z')
  492.                         self.document.getroot().append(temp)
  493.  
  494.     # ----- workaround to fix Effect() performance with large selections
  495.  
  496.     def collect_ids(self, doc=None):
  497.         """Iterate all elements, build id dicts (doc_ids, selected)."""
  498.         doc = self.document if doc is None else doc
  499.         id_list = list(self.options.ids)
  500.         for node in doc.getroot().iter(tag=etree.Element):
  501.             if 'id' in node.attrib:
  502.                 node_id = node.get('id')
  503.                 self.doc_ids[node_id] = 1
  504.                 if node_id in id_list:
  505.                     self.svg.selected[node_id] = node
  506.                     id_list.remove(node_id)
  507.  
  508.     def getselected(self):
  509.         """Overload Effect() method."""
  510.         self.collect_ids()
  511.  
  512.     def getdocids(self):
  513.         """Overload Effect() method."""
  514.         pass
  515.  
  516.  
  517. if __name__ == '__main__':
  518.     PathOps().run()
  519.  
  520. # vim: et shiftwidth=4 tabstop=8 softtabstop=4 fileencoding=utf-8 textwidth=79
  521.  
Advertisement
Add Comment
Please, Sign In to add comment
Advertisement