pythoninkscape

Performing Union Operation using Inkscape Extension on 3 Groups But Weird Result


I made an extension to perform union operation on each selected group, but I got a weird result.

For example, there are three selected groups of rectangles:

Example - Three Groups of Rectangles

After using the extension on them, only the first group gets converted into a union path, the rest get duplicated and they aren't in their original positions anymore:

Weird Result

The expected result should be three union paths:

Expected Result

Here's the extension (save it as "union_operation_on_each_selected_group.py"):

import inkex
import tempfile, os, shutil
from uuid import uuid4
from inkex import Group, Circle, Ellipse, Line, PathElement, Polygon, Polyline, Rectangle, Use
from inkex.paths import Path
from inkex.command import call
from math import ceil
from lxml import etree

def process_svg(svg, action_string):

    temp_folder = tempfile.mkdtemp()

    # Create a random filename for svg
    svg_temp_filepath = os.path.join(temp_folder, f'original_{str(uuid4())}.svg')

    with open(svg_temp_filepath, 'w') as output_file:
        svg_string = svg.tostring().decode('utf-8')
        output_file.write(svg_string)

    processed_svg_temp_filepath = os.path.join(temp_folder, f'processed_{str(uuid4())}.svg')

    my_actions = '--actions='

    export_action_string = my_actions + f'export-filename:{processed_svg_temp_filepath};{action_string}export-do;'

    # Run the command line
    cmd_selection_list = inkex.command.inkscape(svg_temp_filepath, export_action_string)

    # Replace the current document with the processed document
    with open(processed_svg_temp_filepath, 'rb') as processed_svg:
        loaded_svg = inkex.elements.load_svg(processed_svg).getroot()

    shutil.rmtree(temp_folder)
    return loaded_svg

def element_list_union(svg, element_list):

    group = None

    action_string = ''
    for element in element_list:
        group = element.getparent()
        action_string = action_string + f'select-by-id:{element.get_id()};'
    action_string = action_string + f'path-union;select-clear;'
    processed_svg = process_svg(svg, action_string)

    to_remove = []
    for child in group:
        to_remove.append(child)
    for item in to_remove:
        group.remove(item)

    for elem in processed_svg:
        group.add(elem)

class UnionizeEachGroupMembers(inkex.EffectExtension):

    def add_arguments(self, pars):

        pars.add_argument("--selection_type_radio", type=str, dest="selection_type_radio", default='all')

    def effect(self):

        SELECTION_TYPE = self.options.selection_type_radio

        if SELECTION_TYPE == 'all':
            selection_list = self.svg.descendants()
        else:
            selection_list = self.svg.selected

        # Filter for only shapes
        selected_elements = selection_list.filter(inkex.ShapeElement)

        if len(selected_elements) < 1:
            inkex.errormsg('Please select at least one object / No Objects found')
            return

        for elem in selected_elements:
            if elem.tag == inkex.addNS('g', 'svg'): # Check if the tag is 'g' in the SVG namespace
                group = elem
                group_id = group.get_id()

                elements_to_union = [child for child in group if isinstance(child, (Circle, Ellipse, Line, PathElement, Polygon, Polyline, Rectangle, Use))]

                if len(elements_to_union) > 0:
                    element_list_union(self.svg, elements_to_union)

if __name__ == '__main__':
    UnionizeEachGroupMembers().run()

And here's the .inx file:

<?xml version="1.0" encoding="UTF-8"?>
<inkscape-extension xmlns="http://www.inkscape.org/namespace/inkscape/extension">
    <name>Union Operation on Each Selected Group</name>
    <id>union_operation_on_each_selected_group</id>

    <!--  Parameters Here -->

    <param name="selection_type_radio" type="optiongroup" appearance="radio" gui-text="Selection Type">
        <option value="selected">Selected Objects</option>
        <option value="all">All Objects</option>
    </param>

    <effect>
        <object-type>all</object-type>
        <effects-menu>
    <!--            <submenu name="Submenu Name"/>-->
        </effects-menu>
    </effect>
    <script>
        <command location="inx" interpreter="python">union_operation_on_each_selected_group.py</command>
    </script>
</inkscape-extension>

You can download the example file here:

Example File

Is it possible to get the expected result using an extension? How could I fix the extension?


Solution

  • Problem is because it exports all elements to processed svg and later it puts all elements in group.

    I had to use select-invert to invert selection and delete to delete other elements, and later select-all to select again previous elements to run path-union. This way it exports only one object. Or rather 3 elements: group with path (create by path-union)(I don't know why it create extra group) and two other elements but they are not displayed but I don't know what it is.


    Full working code:

    For test I used temporary folder with prefix inkex_ and skiped shutil.rmtree() so I could easily check what it wrote in proccesed file.

    I also first create list with all commands and later convert them to one string using
    action_string = ";".join(action_commands). It allowed me to add new commands in easy way.
    (I tested also export-id and export-id-only but it doesn't export page size and later objects are in wrong place)

    And old elements can be removed from group using one command group.clear()

    I was thinking to add elements to group.getparent() instead of group because currently it creates two nested groups - original group (in which were selected items) and extra group from processed file.

    import inkex
    import tempfile, os, shutil
    from uuid import uuid4
    from inkex import Group, Circle, Ellipse, Line, PathElement, Polygon, Polyline, Rectangle, Use
    from inkex.paths import Path
    from inkex.command import call
    from math import ceil
    from lxml import etree
    
    msg = inkex.errormsg  # to have shorter command to write
    
    def process_svg(svg, action_commands):
    
        temp_folder = tempfile.mkdtemp(prefix='inkex_')
    
        # --- keep original ---
    
        # Create a random filename for svg
        original_svg_temp_filepath = os.path.join(temp_folder, f'original_{str(uuid4())}.svg')
    
        msg(f"{original_svg_temp_filepath = }")
    
        with open(original_svg_temp_filepath, 'w') as output_file:
            svg_string = svg.tostring().decode('utf-8')
            output_file.write(svg_string)
    
        # --- create new ---
    
        processed_svg_temp_filepath = os.path.join(temp_folder, f'processed_{str(uuid4())}.svg')
    
        action_commands.append(f'export-filename:{processed_svg_temp_filepath}')
        action_commands.append('export-do')
    
        action_string = ";".join(action_commands)
    
        my_actions = f'--actions={action_string}'
    
        msg(f"{my_actions = }")
    
        # Run the command line
        cmd_selection_list = inkex.command.inkscape(original_svg_temp_filepath, my_actions)
    
        # --- load new ---
    
        # Replace the current document with the processed document
    
        with open(processed_svg_temp_filepath, 'rb') as processed_svg:
            loaded_svg = inkex.elements.load_svg(processed_svg).getroot()
    
        #shutil.rmtree(temp_folder)
    
        return loaded_svg
    
    def element_list_union(svg, element_list):
        msg(f"{len(element_list) = }")
    
        group = None
    
        action_commands = []
    
        for element in element_list:
            group = element.getparent()
            #msg(f"{group = }")
            action_commands.append(f'select-by-id:{element.get_id()}')
    
        action_commands.append('select-invert')
        action_commands.append('delete')
        action_commands.append('select-all')
        action_commands.append('path-union')
        #action_commands.append('select-clear')
    
        processed_svg = process_svg(svg, action_commands)
    
        #msg(f"{len(group) = }")
        group.clear()
        #msg(f"{len(group) = }")
    
        msg(f"{len(processed_svg) = }")
    
        for elem in processed_svg:
            #msg(f"{elem = }")
            group.add(elem)
            #group.getparent().add(elem)
    
    class UnionizeEachGroupMembers(inkex.EffectExtension):
    
        def add_arguments(self, pars):
    
            pars.add_argument("--selection_type_radio", type=str, dest="selection_type_radio", default='all')
    
        def effect(self):
    
            SELECTION_TYPE = self.options.selection_type_radio
    
            if SELECTION_TYPE == 'all':
                selection_list = self.svg.descendants()
            else:
                selection_list = self.svg.selected
    
            msg(selection_list)
            # Filter for only shapes
            selected_elements = selection_list.filter(inkex.ShapeElement)
    
            if len(selected_elements) < 1:
                inkex.errormsg('Please select at least one object / No Objects found')
                return
    
            for elem in selected_elements:
                if elem.tag == inkex.addNS('g', 'svg'): # Check if the tag is 'g' in the SVG namespace
                    group = elem
                    group_id = group.get_id()
    
                    elements_to_union = [child for child in group if isinstance(child, (Circle, Ellipse, Line, PathElement, Polygon, Polyline, Rectangle, Use))]
    
                    if len(elements_to_union) > 0:
                        element_list_union(self.svg, elements_to_union)
    
    if __name__ == '__main__':
        UnionizeEachGroupMembers().run()
    

    Extra information if someone would be interested.