From 8b70225a768897d9dc99f617a052c5bbf79b3846 Mon Sep 17 00:00:00 2001 From: Antoine Beyeler <49431240+abey79@users.noreply.github.com> Date: Sun, 16 Jan 2022 21:24:08 +0100 Subject: [PATCH] Added sorting by attribute to `read` command (#378) - added vpype.read_svg_by_attribute() to read SVG sorting geometries by attribute(s) - multiple refactoring in vpype/io.py - added `--attr` option to `read` command to enable sorty by attribute - `read` now use single-layer mode when `--layer` is used, even if `--single-layer` is not used - updated tests - added `global_opt` param to `vpype_cli.execute()` - added cookbook section Fixes #35 Fixes #36 --- CHANGELOG.md | 4 + docs/cookbook.rst | 21 ++ .../misc/multilayer_by_attributes.svg | 24 ++ tests/test_commands.py | 1 + tests/test_files.py | 35 +++ vpype/io.py | 229 ++++++++++++++---- vpype_cli/cli.py | 9 +- vpype_cli/read.py | 85 ++++--- 8 files changed, 335 insertions(+), 73 deletions(-) create mode 100644 tests/data/test_svg/misc/multilayer_by_attributes.svg diff --git a/CHANGELOG.md b/CHANGELOG.md index f72ec16b..95a266c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ New features and improvements: * The new `pens` command can apply a predefined or custom scheme on multiple layers at once. Two schemes, `rgb` and `cmyk`, are included and others may be defined in the configuration file. * The `show` and `write` commands were updated to take into account these new layer properties. +* The `read` command can now optionally sort geometries by attributes (e.g. stroke color, stroke width, etc.) instead of by SVG layer (#378) + * The `read` and `write` commands now preserve a sub-set of SVG attributes (experimental) (#359) The `read` command now seeks for SVG attributes (e.g. `stroke-dasharray`) which are shared by all geometries in each layer. When found, such attributes are saved as layer properties (with their name prefixed with `svg:`, e.g. `svg:stroke-dasharray`). The `write` command can optionally restore these attributes in the output SVG (using the `--restore-attribs`), thereby maintaining some of the visual aspects of the original SVG (e.g. dashed lines). @@ -37,6 +39,8 @@ New features and improvements: API changes: * `vpype.Document` and `vpype.LineCollection` have additional members to manage properties through the `vpype._MetadataMixin` mix-in class (#359) +* Added `vpype.read_svg_by_attribute()` to read SVG while sorting geometries by arbitrary attributes (#378) +* Added an argument to `vpype_cli.execute()` to pass global option such as `--verbose` (#378) Other changes: * Renamed the bundled config file to `vpype_config.toml` (#359) diff --git a/docs/cookbook.rst b/docs/cookbook.rst index 7dabcdeb..22b3ec1b 100644 --- a/docs/cookbook.rst +++ b/docs/cookbook.rst @@ -50,6 +50,27 @@ This command will :ref:`cmd_read` a SVG file, add a single-line :ref:`cmd_frame` $ vpype read input.svg frame --offset 5cm write output.svg + +Preserve color (or other attributes) when reading SVG +===================================================== + +By default, the :ref:`cmd_read` command sorts geometries into layers based on the input SVG's top-level groups, akin to Inkscape's layers. Stroke color is preserved *only* if it is identical for every geometries within a layer. + +When preserving the color is desirable, the :ref:`cmd_read` command can sort geometries by colors instead of by top-level groups. This is achieved by using the :option:`--attr ` option:: + + $ vpype read --attr stroke input.svg [...] + +Here, we tell the :ref:`cmd_read` command to sort geometry by ``stroke``, which is the SVG attribute that defines the color of an element. As a result, a layer will be created for each different color encountered in the input SVG file. + +The same applies for any SVG attributes, even those not explicitly supported by *vpype*. For example, ``--attr stroke-width`` will sort layers by stroke width and ``--attr stroke-dasharray`` by type of stroke dash pattern. + +Multiple attributes can even be provided:: + + $ vpype read --attr stroke --attr stroke-width input.svg [...] + +In this case, a layer will be created for each unique combination of color and stroke width. + + Make a previsualisation SVG =========================== diff --git a/tests/data/test_svg/misc/multilayer_by_attributes.svg b/tests/data/test_svg/misc/multilayer_by_attributes.svg new file mode 100644 index 00000000..c86323ad --- /dev/null +++ b/tests/data/test_svg/misc/multilayer_by_attributes.svg @@ -0,0 +1,24 @@ + + + + + + image/svg+xml + vpype rect 10 10 200 200 rect -l 2 400 400 200 300 write -p 600x600 tests/data/test_svg/benchmark/multilayer.svg + + 2020-11-24T15:32:35.445811 + + + + + + + + + + + + + + + diff --git a/tests/test_commands.py b/tests/test_commands.py index 8de1c261..6ac8ef7b 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -35,6 +35,7 @@ class Command: Command("ellipse 0 0 2 4"), Command(f"read '{EXAMPLE_SVG}'", preserves_metadata=False), Command(f"read -m '{EXAMPLE_SVG}'", preserves_metadata=False), + Command(f"read -a stroke '{EXAMPLE_SVG}'", preserves_metadata=False), Command("write -f svg -"), Command("write -f hpgl -d hp7475a -p a4 -"), Command("rotate 0"), diff --git a/tests/test_files.py b/tests/test_files.py index ad035c2a..09b12386 100644 --- a/tests/test_files.py +++ b/tests/test_files.py @@ -2,11 +2,13 @@ import difflib import os import re +from typing import Set import numpy as np import pytest import vpype as vp +import vpype_cli from vpype_cli import DebugData, cli from .utils import TEST_FILE_DIRECTORY @@ -367,3 +369,36 @@ def test_read_layer_name(runner): layer 3 property vp:name: (str) my layer 3 """ ) + + +def test_read_by_attribute(): + def _prop_set(document: vp.Document, prop: str) -> Set: + return {layer.property(prop) for layer in document.layers.values()} + + file = TEST_FILE_DIRECTORY / "misc" / "multilayer_by_attributes.svg" + doc = vp.read_svg_by_attributes(str(file), ["stroke"], 0.1) + assert len(doc.layers) == 2 + assert _prop_set(doc, "vp:color") == {vp.Color("#906"), vp.Color("#00f")} + + doc = vp.read_svg_by_attributes(str(file), ["stroke", "stroke-width"], 0.1) + assert len(doc.layers) == 3 + assert _prop_set(doc, "vp:color") == {vp.Color("#906"), vp.Color("#00f")} + assert _prop_set(doc, "vp:pen_width") == pytest.approx({1, 4}) + + +def test_read_layer_assumes_single_layer(runner, caplog): + test_file = TEST_FILE_DIRECTORY / "misc" / "multilayer.svg" + doc = vpype_cli.execute(f"read --layer 2 '{test_file}'", global_opt="-v") + + assert "assuming single-layer mode" in caplog.text + assert len(doc.layers) == 1 + assert 2 in doc.layers + + +def test_read_single_layer_attr_warning(runner, caplog): + test_file = TEST_FILE_DIRECTORY / "misc" / "multilayer_by_attributes.svg" + doc = vpype_cli.execute(f"read -m -a stroke '{test_file}'") + + assert "`--attr` is ignored in single-layer mode" in caplog.text + assert len(doc.layers) == 1 + assert 1 in doc.layers diff --git a/vpype/io.py b/vpype/io.py index c441ffec..517b7648 100644 --- a/vpype/io.py +++ b/vpype/io.py @@ -1,10 +1,12 @@ """File import/export functions. """ +import collections import copy +import dataclasses import datetime import math import re -from typing import Any, Dict, Iterator, List, Optional, TextIO, Tuple, Union, cast +from typing import Any, Dict, Iterable, Iterator, List, Optional, TextIO, Tuple, Union, cast from xml.etree import ElementTree import click @@ -29,7 +31,13 @@ from .model import Document, LineCollection from .utils import UNITS -__all__ = ["read_svg", "read_multilayer_svg", "write_svg", "write_hpgl"] +__all__ = [ + "read_svg", + "read_multilayer_svg", + "read_svg_by_attributes", + "write_svg", + "write_hpgl", +] _DEFAULT_WIDTH = 1000 @@ -75,14 +83,13 @@ def get(self) -> np.ndarray: return self._stack -_PathListType = List[ - Union[ - # for actual paths and shapes transformed into paths - svgelements.Path, - # for the special case of Polygon and Polylines - List[Union[svgelements.PathSegment, svgelements.Polygon, svgelements.Polyline]], - ] +_PathType = Union[ + # for actual paths and shapes transformed into paths + svgelements.Path, + # for the special case of Polygon and Polylines + List[Union[svgelements.PathSegment, svgelements.Polygon, svgelements.Polyline]], ] +_PathListType = List[_PathType] _NAMESPACED_PROPERTY_RE = re.compile(r"{([-a-zA-Z0-9@:%._+~#=/]+)}([a-zA-Z0-9]+)") @@ -145,9 +152,50 @@ def _intersect_dict(a: Dict[str, Any], b: Dict[str, Any]) -> Dict[str, Any]: return dict(a.items() & b.items()) +def _merge_metadata( + base_metadata: Optional[Dict[str, Any]], additional_metadata: Dict[str, Any] +) -> Dict[str, Any]: + """Merge two metadata dictionaries with handling of uninitialized base dictionary.""" + + if base_metadata is None: + base_metadata = additional_metadata + else: + base_metadata = _intersect_dict(base_metadata, additional_metadata) + return base_metadata + + +def _element_to_paths(elem: svgelements.SVGElement) -> Optional[_PathType]: + """Convert a SVG element into a path object that can be processed by + :func:`_flattened_paths_to_line_collection` + + Args: + elem: the element to convert + + Returns: + the path object or None if the element should be ignored + """ + if isinstance(elem, svgelements.Path): + if len(elem) != 0: + return elem + elif isinstance(elem, (svgelements.Polyline, svgelements.Polygon)): + # Here we add a "fake" path containing just the Polyline/Polygon, + # to be treated specifically by _convert_flattened_paths. + path = [svgelements.Move(elem.points[0]), elem] + if isinstance(elem, svgelements.Polygon): + path.append(svgelements.Close(elem.points[-1], elem.points[0])) + return path + elif isinstance(elem, svgelements.Shape): + e = svgelements.Path(elem) + e.reify() # In some cases the shape could not have reified, the path must. + if len(e) != 0: + return e + + return None + + def _extract_paths( group: svgelements.Group, recursive -) -> Tuple[_PathListType, Optional[Dict[str, str]]]: +) -> Tuple[_PathListType, Optional[Dict[str, Any]]]: """Extract everything from the provided SVG group.""" if recursive: @@ -155,7 +203,7 @@ def _extract_paths( else: everything = group paths = [] - metadata: Optional[Dict[str, str]] = None + metadata: Optional[Dict[str, Any]] = None for elem in everything: if hasattr(elem, "values") and elem.values.get("visibility", "") in ( "hidden", @@ -163,41 +211,72 @@ def _extract_paths( ): continue - if isinstance(elem, svgelements.Path): - if len(elem) != 0: - paths.append(elem) - elif isinstance(elem, (svgelements.Polyline, svgelements.Polygon)): - # Here we add a "fake" path containing just the Polyline/Polygon, - # to be treated specifically by _convert_flattened_paths. - path = [svgelements.Move(elem.points[0]), elem] - if isinstance(elem, svgelements.Polygon): - path.append(svgelements.Close(elem.points[-1], elem.points[0])) - paths.append(path) - elif isinstance(elem, svgelements.Shape): - e = svgelements.Path(elem) - e.reify() # In some cases the shape could not have reified, the path must. - if len(e) != 0: - paths.append(e) - else: + path = _element_to_paths(elem) + if path is None: continue + else: + paths.append(path) # apply union on metadata - if metadata is None: - metadata = _extract_metadata_from_element(elem) - else: - metadata = _intersect_dict(metadata, _extract_metadata_from_element(elem)) + metadata = _merge_metadata(metadata, _extract_metadata_from_element(elem)) return paths, metadata -def _convert_flattened_paths( - group: svgelements.Group, - recursive: bool, +def _extract_paths_by_attributes( + group: svgelements.Group, attributes: Iterable[str] +) -> List[Tuple[_PathListType, Optional[Dict[str, Any]]]]: + """Extract everything from the provided SVG group, grouped by the specified attributes. + + The paths are grouped by unique combinations of the provided attributes. + + Args: + group: SVG group from which to extract paths + attributes: attributes by which to group paths + + Returns: + list of tuple containing the list of paths and the associated metadata + """ + + @dataclasses.dataclass + class _LayerDesc: + paths: _PathListType = dataclasses.field(default_factory=list) + metadata: Optional[Dict[str, Any]] = None + + attributes = tuple(attributes) + results: Dict[Tuple, _LayerDesc] = collections.defaultdict(lambda: _LayerDesc()) + + for elem in group.select(): + if hasattr(elem, "values") and elem.values.get("visibility", "") in ( + "hidden", + "collapse", + ): + continue + + key = tuple(elem.values.get(attr, None) for attr in attributes) + + path = _element_to_paths(elem) + if path is None: + continue + else: + results[key].paths.append(path) + + # apply union on metadata + results[key].metadata = _merge_metadata( + results[key].metadata, _extract_metadata_from_element(elem) + ) + + return [(desc.paths, desc.metadata) for desc in results.values()] + + +def _flattened_paths_to_line_collection( + paths: _PathListType, quantization: float, simplify: bool, parallel: bool, + metadata: Optional[Dict[str, Any]] = None, ) -> LineCollection: - """Convert a list of SVG group to a :class:`LineCollection`. + """Convert a path list to a :class:`LineCollection`. The resulting :class:`vpype.LineCollection` instance's metadata contains all properties (as extracted with :func:`_extract_metadata_from_element`) whose value is identical for @@ -207,18 +286,18 @@ def _convert_flattened_paths( propagated to enclosed elements by svgelements. Args: - group: SVG group to process - recursive: defines whether the group should be parsed recursively or not + paths: paths to process quantization: maximum length of linear elements to approximate curve paths simplify: should Shapely's simplify be run on curved elements after quantization parallel: enable multiprocessing + metadata: if provided, metadata to include in the returned + :class:`vpype.LineCollection` instance Returns: - new :class:`LineCollection` instance containing the converted geometries + new :class:`LineCollection` instance containing the converted geometries and the + provided metadata """ - paths, metadata = _extract_paths(group, recursive) - def _process_path(path): if len(path) == 0: return [] @@ -316,7 +395,8 @@ def read_svg( # default width is for SVG with % width/height svg = svgelements.SVG.parse(file, width=default_width, height=default_height) - lc = _convert_flattened_paths(svg, True, quantization, simplify, parallel) + paths, metadata = _extract_paths(svg, True) + lc = _flattened_paths_to_line_collection(paths, quantization, simplify, parallel, metadata) if crop: lc.crop(0, 0, svg.width, svg.height) @@ -368,7 +448,8 @@ def read_multilayer_svg( document = Document(metadata=_extract_metadata_from_element(svg, False)) # non-group top level elements are loaded in layer 1 - lc = _convert_flattened_paths(svg, False, quantization, simplify, parallel) + paths, metadata = _extract_paths(svg, False) + lc = _flattened_paths_to_line_collection(paths, quantization, simplify, parallel, metadata) if not lc.is_empty(): document.add(lc, 1) document.layers[1].metadata = lc.metadata @@ -393,7 +474,10 @@ def _find_groups(group: svgelements.Group) -> Iterator[svgelements.Group]: else: lid = i + 1 - lc = _convert_flattened_paths(g, True, quantization, simplify, parallel) + paths, metadata = _extract_paths(g, True) + lc = _flattened_paths_to_line_collection( + paths, quantization, simplify, parallel, metadata + ) if not lc.is_empty(): # deal with the case of layer 1, which may already be initialized with top-level @@ -420,6 +504,65 @@ def _find_groups(group: svgelements.Group) -> Iterator[svgelements.Group]: return document +def read_svg_by_attributes( + file: Union[str, TextIO], + attributes: Iterable[str], + quantization: float, + crop: bool = True, + simplify: bool = False, + parallel: bool = False, + default_width: float = _DEFAULT_WIDTH, + default_height: float = _DEFAULT_HEIGHT, +) -> "Document": + """Read a SVG file by sorting geometries by unique combination of provided attributes. + + 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: + file: path of the SVG file or stream object + attributes: attributes by which the object should be sorted + quantization: maximum size of segment used to approximate curved geometries + crop: crop the geometries to the SVG boundaries + simplify: run Shapely's simplify on loaded geometry + parallel: enable multiprocessing (only recommended for ``simplify=True`` and SVG with + many curves) + default_width: default width if not provided by SVG or if a percent width is provided + default_height: default height if not provided by SVG or if a percent height is + provided + + Returns: + :class:`Document` instance with the imported geometries and its page size set the the + SVG dimensions + """ + + svg = svgelements.SVG.parse(file, width=default_width, height=default_height) + document = Document(metadata=_extract_metadata_from_element(svg, False)) + + for paths, metadata in _extract_paths_by_attributes(svg, attributes): + lc = _flattened_paths_to_line_collection( + paths, quantization, simplify, parallel, metadata + ) + + if not lc.is_empty(): + lid = document.free_id() + document.add(lc, lid) + document.layers[lid].metadata = lc.metadata + + document.page_size = (svg.width, svg.height) + + if crop: + document.crop(0, 0, svg.width, svg.height) + + # Because of how svgelements works, all the level metadata is propagated to every + # nested tag. As a result, we need to subtract global properties from the layer ones. + for layer in document.layers.values(): + layer.metadata = dict(layer.metadata.items() - document.metadata.items()) + + return document + + _WRITE_SVG_RESTORE_EXCLUDE_LIST = ( "svg:display", "svg:visibility", diff --git a/vpype_cli/cli.py b/vpype_cli/cli.py index daf41ba6..b3ab2ba5 100644 --- a/vpype_cli/cli.py +++ b/vpype_cli/cli.py @@ -346,7 +346,9 @@ def preprocess_argument_list(args: List[str], cwd: Union[str, None] = None) -> L return result -def execute(pipeline: str, document: Optional[vp.Document] = None) -> vp.Document: +def execute( + pipeline: str, document: Optional[vp.Document] = None, global_opt: str = "" +) -> vp.Document: """Execute a vpype pipeline. This function serves as a Python API to vpype's pipeline. It can be used from a regular @@ -373,6 +375,7 @@ def execute(pipeline: str, document: Optional[vp.Document] = None) -> vp.Documen Args: pipeline: vpype pipeline as would be used with ``vpype`` CLI document: if provided, is perloaded in the pipeline before the first command executes + global_opt: global CLI option (e.g. "--verbose") Returns: pipeline's content after the last command executes @@ -394,6 +397,8 @@ def vsketchoutput(doc): out_doc.extend(doc) return doc - args = ("vsketchinput " if document else "") + pipeline + " vsketchoutput" + args = " ".join( + [global_opt, ("vsketchinput " if document else ""), pipeline, "vsketchoutput"] + ) cli.main(prog_name="vpype", args=shlex.split(args), standalone_mode=False) return out_doc diff --git a/vpype_cli/read.py b/vpype_cli/read.py index 504bb542..5bce6516 100644 --- a/vpype_cli/read.py +++ b/vpype_cli/read.py @@ -1,19 +1,10 @@ import logging import sys -from typing import Optional, Tuple +from typing import List, Optional, Tuple import click -from vpype import ( - Document, - LayerType, - LengthType, - PageSizeType, - global_processor, - read_multilayer_svg, - read_svg, - single_to_layer_id, -) +import vpype as vp from .cli import cli @@ -26,13 +17,20 @@ @click.option( "-l", "--layer", - type=LayerType(accept_new=True), + type=vp.LayerType(accept_new=True), help="Target layer or 'new' (single layer mode only).", ) +@click.option( + "-a", + "--attr", + type=str, + multiple=True, + help="Attribute by which geometries should be grouped", +) @click.option( "-q", "--quantization", - type=LengthType(), + type=vp.LengthType(), default="0.1mm", help="Maximum length of segments approximating curved elements (default: 0.1mm).", ) @@ -60,7 +58,7 @@ @click.option( "-ds", "--display-size", - type=PageSizeType(), + type=vp.PageSizeType(), default="a4", help=( "Display size to use for SVG with width/height expressed as percentage or missing " @@ -74,19 +72,20 @@ default=False, help="Use landscape orientation ofr display size.", ) -@global_processor +@vp.global_processor def read( - document: Document, + document: vp.Document, file, single_layer: bool, layer: Optional[int], + attr: List[str], quantization: float, simplify: bool, parallel: bool, no_crop: bool, display_size: Tuple[float, float], display_landscape: bool, -) -> Document: +) -> vp.Document: """Extract geometries from a SVG file. FILE may be a file path path or a dash (-) to read from the standard input instead. @@ -107,9 +106,15 @@ def read( - 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. + Alternatively, geometries may be sorted into layers based on their attributes, such as + color or stroke width. This is enabled by using the `--attr` option with the attribute + to be considered. Multiple `--attr` options may be passed with different attributes. In + this case, layers will be created for each unique combination of the provided attributes. + + 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. If the `--layer` + option is used, `--single-layer` is assumed even if not explicitly provided. This command only extracts path elements as well as primitives (rectangles, ellipses, lines, polylines, polygons). Other elements such as text and bitmap images are discarded, @@ -146,10 +151,18 @@ def read( Examples: - Multi-layer import: + Multi-layer SVG import: vpype read input_file.svg [...] + Import SVG, sorting geometries by stroke color: + + vpype read --attr stroke input_file.svg [...] + + Import SVG, sorting geometries by stroke color and width: + + vpype read --attr stroke --attr stroke-width input_file.svg [...] + Single-layer import: vpype read --single-layer input_file.svg [...] @@ -174,8 +187,15 @@ def read( if file == "-": file = sys.stdin + if layer is not None and not single_layer: + single_layer = True + logging.info("read: `--layer` provided, assuming single-layer mode") + if single_layer: - lc, width, height = read_svg( + if len(attr) > 0: + logging.warning("read: `--attr` is ignored in single-layer mode") + + lc, width, height = vp.read_svg( file, quantization=quantization, crop=not no_crop, @@ -185,13 +205,11 @@ def read( default_height=height, ) - document.add(lc, single_to_layer_id(layer, document)) + document.add(lc, vp.single_to_layer_id(layer, document)) document.extend_page_size((width, height)) else: - if layer is not None: - logging.warning("read: target layer is ignored in multi-layer mode") - document.extend( - read_multilayer_svg( + if len(attr) == 0: + doc = vp.read_multilayer_svg( file, quantization=quantization, crop=not no_crop, @@ -200,6 +218,17 @@ def read( default_width=width, default_height=height, ) - ) + else: + doc = vp.read_svg_by_attributes( + file, + attributes=attr, + quantization=quantization, + crop=not no_crop, + simplify=simplify, + parallel=parallel, + default_width=width, + default_height=height, + ) + document.extend(doc) return document