Repository: abey79/vpype

Issue:
`read` must (optionally) preserve the layer structure of the input SVG



Discussion:

Resolving patch:
diff --git a/setup.py b/setup.py
--- a/setup.py
+++ b/setup.py
@@ -18,7 +18,7 @@
     license=license,
     packages=["vpype", "vpype_cli"],
     install_requires=[
-        'Click',
+        'click>=7.1',
         'click-plugins',
         'matplotlib',
         'scipy',  # scipy is needed to optimize svgpathtools' curve linearization
diff --git a/vpype/model.py b/vpype/model.py
--- a/vpype/model.py
+++ b/vpype/model.py
@@ -2,11 +2,15 @@
 Implementation of vpype's data model
 """
 import math
+import re
 from typing import Union, Iterable, List, Dict, Tuple, Optional
+from xml.etree.ElementTree import Element
 
 import numpy as np
 import svgpathtools as svg
 from shapely.geometry import MultiLineString, LineString, LinearRing
+from svgpathtools import SVG_NAMESPACE
+from svgpathtools.document import flatten_group
 
 from .utils import convert
 from .line_index import LineIndex
@@ -25,30 +29,15 @@ def as_vector(a: np.ndarray):
     return a.view(dtype=float).reshape(len(a), 2)
 
 
-def read_svg(
-    filename: str, quantization: float, simplify: bool = False, return_size: bool = False
-) -> Union["LineCollection", Tuple["LineCollection", float, float]]:
-    """
-    Read a SVG file an return its content as a :class:`LineCollection` instance. All curved
-    geometries are chopped in segments no longer than the value of *quantization*.
-    Optionally, the geometries are simplified using Shapely, using the value of *quantization*
-    as tolerance.
+def _calculate_page_size(root: Element) -> Tuple[float, float, float, float, float, float]:
+    """Interpret the viewBox, width and height attribs and compute proper scaling coefficients.
 
-    :param filename: path of the SVG file
-    :param quantization: maximum size of segment used to approximate curved geometries
-    :param simplify: run Shapely's simplify on loaded geometry
-    :param return_size: if True, return a size 3 Tuple containing the geometries and the SVG
-        width and height
-    :return: imported geometries, and optionally width and height of the SVG
-    """
-
-    doc = svg.Document(filename)
-    results = doc.flatten_all_paths()
-    root = doc.tree.getroot()
-
-    # we must interpret correctly the viewBox, width and height attribs in order to scale
-    # the file content to proper pixels
+    Args:
+        root: SVG's root element
 
+    Returns:
+        tuple of width, height, scale X, scale Y, offset X, offset Y
+    """
     width = height = None
     if "viewBox" in root.attrib:
         # A view box is defined so we must correctly scale from user coordinates
@@ -72,8 +61,33 @@ def read_svg(
         offset_x = 0
         offset_y = 0
 
+    return width, height, scale_x, scale_y, offset_x, offset_y
+
+
+def _convert_flattened_paths(
+    paths: List,
+    quantization: float,
+    scale_x: float,
+    scale_y: float,
+    offset_x: float,
+    offset_y: float,
+    simplify: bool,
+) -> "LineCollection":
+    """Convert a list of FlattenedPaths to a :class:`LineCollection`.
+
+    Args:
+        paths: list of FlattenedPaths
+        quantization: maximum length of linear elements to approximate curve paths
+        scale_x, scale_y: scale factor to apply
+        offset_x, offset_y: offset to apply
+        simplify: should Shapely's simplify be run
+
+    Returns:
+        new :class:`LineCollection` instance containing the converted geometries
+    """
+
     lc = LineCollection()
-    for result in results:
+    for result in paths:
         # Here we load the sub-part of the path element. If such sub-parts are connected,
         # we merge them in a single line (e.g. line string, etc.). If there are disconnection
         # in the path (e.g. multiple "M" commands), we create several lines
@@ -111,6 +125,35 @@ def read_svg(
         mls = lc.as_mls()
         lc = LineCollection(mls.simplify(tolerance=quantization))
 
+    return lc
+
+
+def read_svg(
+    filename: str, quantization: float, simplify: bool = False, return_size: bool = False
+) -> Union["LineCollection", Tuple["LineCollection", float, float]]:
+    """Read a SVG file an return its content as a :class:`LineCollection` instance.
+
+    All curved geometries are chopped in segments no longer than the value of *quantization*.
+    Optionally, the geometries are simplified using Shapely, using the value of *quantization*
+    as tolerance.
+
+    Args:
+        filename: path of the SVG file
+        quantization: maximum size of segment used to approximate curved geometries
+        simplify: run Shapely's simplify on loaded geometry
+        return_size: if True, return a size 3 Tuple containing the geometries and the SVG
+            width and height
+
+    Returns:
+        imported geometries, and optionally width and height of the SVG
+    """
+
+    doc = svg.Document(filename)
+    width, height, scale_x, scale_y, offset_x, offset_y = _calculate_page_size(doc.root)
+    lc = _convert_flattened_paths(
+        doc.flatten_all_paths(), quantization, scale_x, scale_y, offset_x, offset_y, simplify,
+    )
+
     if return_size:
         if width is None or height is None:
             _, _, width, height = lc.bounds()
@@ -119,6 +162,92 @@ def read_svg(
         return lc
 
 
+def read_multilayer_svg(
+    filename: str, quantization: float, simplify: bool = False, return_size: bool = False
+) -> Union["VectorData", Tuple["VectorData", float, float]]:
+    """Read a multilayer SVG file and return its content as a :class:`VectorData` instance
+    retaining the SVG's layer structure.
+
+    Each top-level group is considered a layer. All non-group, top-level elements are imported
+    in layer 1.
+
+    Groups are matched to layer ID according their `inkscape:label` attribute, their `id`
+    attribute or their appearing order, in that order of priority. Labels are stripped of
+    non-numeric characters and the remaining is used as layer ID. Lacking numeric characters,
+    the appearing order is used. If the label is 0, its changed to 1.
+
+    All curved geometries are chopped in segments no longer than the value of *quantization*.
+    Optionally, the geometries are simplified using Shapely, using the value of *quantization*
+    as tolerance.
+
+    Args:
+        filename: path of the SVG file
+        quantization: maximum size of segment used to approximate curved geometries
+        simplify: run Shapely's simplify on loaded geometry
+        return_size: if True, return a size 3 Tuple containing the geometries and the SVG
+            width and height
+
+    Returns:
+         imported geometries, and optionally width and height of the SVG
+    """
+
+    doc = svg.Document(filename)
+
+    width, height, scale_x, scale_y, offset_x, offset_y = _calculate_page_size(doc.root)
+
+    vector_data = VectorData()
+
+    # non-group top level elements are loaded in layer 1
+    top_level_elements = doc.flatten_all_paths(group_filter=lambda x: x is doc.root)
+    if top_level_elements:
+        vector_data.add(
+            _convert_flattened_paths(
+                top_level_elements,
+                quantization,
+                scale_x,
+                scale_y,
+                offset_x,
+                offset_y,
+                simplify,
+            ),
+            1,
+        )
+
+    for i, g in enumerate(doc.root.iterfind("svg:g", SVG_NAMESPACE)):
+        # compute a decent layer ID
+        lid_str = re.sub(
+            "[^0-9]", "", g.get("{http://www.inkscape.org/namespaces/inkscape}label") or ""
+        )
+        if not lid_str:
+            lid_str = re.sub("[^0-9]", "", g.get("id") or "")
+        if lid_str:
+            lid = int(lid_str)
+            if lid == 0:
+                lid = 1
+        else:
+            lid = i + 1
+
+        vector_data.add(
+            _convert_flattened_paths(
+                flatten_group(g, g),
+                quantization,
+                scale_x,
+                scale_y,
+                offset_x,
+                offset_y,
+                simplify,
+            ),
+            lid,
+        )
+
+    if return_size:
+        if width is None or height is None:
+            _, _, width, height = vector_data.bounds()
+        return vector_data, width, height
+    else:
+        return vector_data
+
+
 def interpolate_line(line: np.ndarray, step: float) -> np.ndarray:
     """
     Compute a linearly interpolated version of *line* with segments of *step* length or
diff --git a/vpype_cli/read.py b/vpype_cli/read.py
--- a/vpype_cli/read.py
+++ b/vpype_cli/read.py
@@ -1,11 +1,29 @@
+import logging
+from typing import Optional
+
 import click
 
-from vpype import LineCollection, Length, generator, read_svg
+from vpype import (
+    Length,
+    read_svg,
+    global_processor,
+    VectorData,
+    read_multilayer_svg,
+    LayerType,
+    single_to_layer_id,
+)
 from .cli import cli
 
 
 @cli.command(group="Input")
 @click.argument("file", type=click.Path(exists=True, dir_okay=False))
+@click.option("-m", "--single-layer", is_flag=True, help="Single layer mode.")
+@click.option(
+    "-l",
+    "--layer",
+    type=LayerType(accept_new=True),
+    help="Target layer or 'new' (single layer mode only).",
+)
 @click.option(
     "-q",
     "--quantization",
@@ -20,20 +38,76 @@
     default=False,
     help="Do not run the implicit simplify on imported geometries.",
 )
-@generator
-def read(file, quantization: float, no_simplify: bool) -> LineCollection:
-    """
-    Extract geometries from a SVG file.
+@global_processor
+def read(
+    vector_data: VectorData,
+    file,
+    single_layer: bool,
+    layer: Optional[int],
+    quantization: float,
+    no_simplify: bool,
+) -> VectorData:
+    """Extract geometries from a SVG file.
+
+    By default, the `read` command attempts to preserve the layer structure of the SVG. In this
+    context, top-level groups (<svg:g>) are each considered a layer. If any, all non-group,
+    top-level SVG elements are imported into layer 1.
+
+    The following logic is used to determine in which layer each SVG top-level group is
+    imported:
+
+        - If a `inkscape:label` attribute is present and contains digit characters, it is
+    stripped of non-digit characters the resulting number is used as target layer. If the
+    resulting number is 0, layer 1 is used instead.
+
+        - If the previous step fails, the same logic is applied to the `id` attribute.
+
+        - If both previous steps fail, the target layer matches the top-level group's order of
+    appearance.
+
+    Using `--single-layer`, the `read` command operates in single-layer mode. In this mode, all
+    geometries are in a single layer regardless of the group structure. The current target
+    layer is used default and can be specified with the `--layer` option.
 
     This command only extracts path elements as well as primitives (rectangles, ellipses,
-    lines, polylines, polygons). In particular, text and bitmap images are discarded, as well
-    as all formatting.
+    lines, polylines, polygons). Other elements such as text and bitmap images are discarded,
+    and so is all formatting.
 
-    All curved primitives (e.g. bezier path, ellipses, etc.) are linearized and approximated
-    by polylines. The quantization length controls the maximum length of individual segments.
+    All curved primitives (e.g. bezier path, ellipses, etc.) are linearized and approximated by
+    polylines. The quantization length controls the maximum length of individual segments.
 
     By default, an implicit line simplification with tolerance set to quantization is executed
     (see `linesimplify` command). This behaviour can be disabled with the `--no-simplify` flag.
+
+    Examples:
+
+        Multi-layer import:
+
+            vpype read input_file.svg [...]
+
+        Single-layer import:
+
+            vpype read --single-layer input_file.svg [...]
+
+        Single-layer import with target layer:
+
+            vpype read --single-layer --layer 3 input_file.svg [...]
+
+        Multi-layer import with specified quantization and line simplification disabled:
+
+            vpype read --quantization 0.01mm --no-simplify input_file.svg [...]
     """
 
-    return read_svg(file, quantization=quantization, simplify=not no_simplify)
+    if single_layer:
+        vector_data.add(
+            read_svg(file, quantization=quantization, simplify=not no_simplify),
+            single_to_layer_id(layer, vector_data),
+        )
+    else:
+        if layer is not None:
+            logging.warning("read: target layer is ignored in multi-layer mode")
+        vector_data.extend(
+            read_multilayer_svg(file, quantization=quantization, simplify=not no_simplify)
+        )
+
+    return vector_data


Test patch:
diff --git a/tests/test_commands.py b/tests/test_commands.py
--- a/tests/test_commands.py
+++ b/tests/test_commands.py
@@ -14,6 +14,7 @@
     "rect 0 0 1 1",
     "circle 0 0 1",
     "read '__ROOT__/examples/bc_template.svg'",
+    "read -m '__ROOT__/examples/bc_template.svg'",
     "write -",
     "rotate 0",
     "scale 1 1",