diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f3b5742..cdfefca7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,7 @@ New features and improvements: ```bash $ vpype read input.svg text --layer 1 "Name: {vp_name} Pen width: {vp_pen_width:.2f}" write output.svg ``` - See the [documentation](https://vpype.readthedocs.io/en/latest/fundamentals.html#cli-property-substitution) for more information and examples. + See the [documentation](https://vpype.readthedocs.io/en/latest/fundamentals.html#property-substitution) for more information and examples. * Added expression substitution to CLI user input (#397) @@ -54,12 +54,11 @@ New features and improvements: * When `--prob` is not used, the `lswap` command now swaps the layer properties as well. * These behaviors can be disabled with the `--no-prop` option. -* Improved the handling of block processors (#395, #397) - - Block processors are commands which, when combined with `begin` and `end`, operate on the sequence they encompass. For example, the sequence `begin grid 2 2 random end` creates a 2x2 grid of random line patches. The infrastructure underlying block processors has been overhauled to increase their usefulness and extensibility. - - * The `grid` block processor now sets variables for expressions in nested commands. - * The `repeat` block processor now sets variables for expressions in nested commands. +* Improved block processors (#395, #397) + + * Simplified and improved the infrastructure underlying block processors for better extensibility. + * The `grid` block processor now adjusts the page size according to its layout. + * The `grid` and `repeat` block processors now sets variables for expressions in nested commands. * Added `forfile` block processor to iterate over a list of file. * Added `forlayer` block processor to iterate over the existing layers. * The `begin` marker is now optional and implied whenever a block processor command is encountered. The following pipelines are thus equivalent: @@ -70,6 +69,8 @@ New features and improvements: *Note*: the `end` marker must always be used to mark the end of a block. * Commands inside the block now have access to the current layer structure and its metadata. This makes their use more predictable. For example, `begin grid 2 2 random --layer new end` now correctly generates patches of random lines on different layers. * The `grid` block processor now first iterate along lines instead of columns. + +* The `read` command now will ignore a missing file if `--no-fail` parameter is used (#397) * Changed the initial default target layer to 1 (#395) diff --git a/README.md b/README.md index 22b6a515..ca7dd7bd 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![PyPI](https://img.shields.io/pypi/v/vpype?label=PyPI&logo=pypi)](https://pypi.org/project/vpype/) [![python](https://img.shields.io/github/languages/top/abey79/vpype)](https://www.python.org) [![Downloads](https://pepy.tech/badge/vpype)](https://pepy.tech/project/vpype) -[![license](https://img.shields.io/github/license/abey79/vpype)](https://vpype.readthedocs.io/en/stable/license.html) +[![license](https://img.shields.io/github/license/abey79/vpype)](https://vpype.readthedocs.io/en/latest/license.html) ![Test](https://img.shields.io/github/workflow/status/abey79/vpype/Lint%20and%20Tests?label=Tests&logo=github) [![codecov](https://codecov.io/gh/abey79/vpype/branch/master/graph/badge.svg?token=CE7FD9D6XO)](https://codecov.io/gh/abey79/vpype) [![Sonarcloud Status](https://sonarcloud.io/api/project_badges/measure?project=abey79_vpype&metric=alert_status)](https://sonarcloud.io/dashboard?id=abey79_vpype) @@ -50,22 +50,22 @@ already exists for plotting [pixel art](https://github.com/abey79/vpype-pixelart [half-toning with hatches](https://github.com/abey79/hatched), and much more. See below for a [list of existing plug-ins](#plug-ins). -_vpype_ is also a [well documented](https://vpype.readthedocs.io/en/stable/api.html) **Python library** +_vpype_ is also a [well documented](https://vpype.readthedocs.io/en/latest/api.html) **Python library** useful to create generative art and tools for plotters. It includes data structures, utility and I/O functions, as well as a hardware-accelerated flexible viewer for vector graphics. For example, the plotter generative art environment [vsketch](https://github.com/abey79/vsketch) is built upon _vpype_. -Check the [documentation](https://vpype.readthedocs.io/en/stable/) for a more thorough introduction to _vpype_. +Check the [documentation](https://vpype.readthedocs.io/en/latest/) for a more thorough introduction to _vpype_. ## How does it work? _vpype_ works by building so-called _pipelines_ of _commands_, where each command's output is fed to the next command's input. -Some commands load geometries into the pipeline (e.g. the [`read`](https://vpype.readthedocs.io/en/stable/reference.html#read) +Some commands load geometries into the pipeline (e.g. the [`read`](https://vpype.readthedocs.io/en/latest/reference.html#read) command which loads geometries from a SVG file). Other commands modify these geometries, e.g. by cropping -them ([`crop`](https://vpype.readthedocs.io/en/stable/reference.html#crop)) or reordering them to minimize pen-up -travels ([`linesort`](https://vpype.readthedocs.io/en/stable/reference.html#linesort)). Finally, some other commands -just read the geometries in the pipeline for display purposes ([`show`](https://vpype.readthedocs.io/en/stable/reference.html#show)) -or output to file ([`write`](https://vpype.readthedocs.io/en/stable/reference.html#write)). +them ([`crop`](https://vpype.readthedocs.io/en/latest/reference.html#crop)) or reordering them to minimize pen-up +travels ([`linesort`](https://vpype.readthedocs.io/en/latest/reference.html#linesort)). Finally, some other commands +just read the geometries in the pipeline for display purposes ([`show`](https://vpype.readthedocs.io/en/latest/reference.html#show)) +or output to file ([`write`](https://vpype.readthedocs.io/en/latest/reference.html#write)). Pipeline are defined using the _vpype_'s CLI (command-line interface) in a terminal by typing `vpype` followed by the list of commands, each with their optional parameters and their arguments: @@ -73,63 +73,115 @@ list of commands, each with their optional parameters and their arguments: ![command line](https://github.com/abey79/vpype/raw/master/docs/images/command_line.svg) This pipeline uses five commands (in bold): -- [`read`](https://vpype.readthedocs.io/en/stable/reference.html#read) loads geometries from a SVG file. -- [`linemerge`](https://vpype.readthedocs.io/en/stable/reference.html#linemerge) merges paths whose extremities are close to each other (within the provided tolerance). -- [`linesort`](https://vpype.readthedocs.io/en/stable/reference.html#linesort) reorder paths such as to minimise the pen-up travel. -- [`crop`](https://vpype.readthedocs.io/en/stable/reference.html#crop), well, crops. -- [`write`](https://vpype.readthedocs.io/en/stable/reference.html#write) export the resulting geometries to a SVG file. +- [`read`](https://vpype.readthedocs.io/en/latest/reference.html#read) loads geometries from a SVG file. +- [`linemerge`](https://vpype.readthedocs.io/en/latest/reference.html#linemerge) merges paths whose extremities are close to each other (within the provided tolerance). +- [`linesort`](https://vpype.readthedocs.io/en/latest/reference.html#linesort) reorder paths such as to minimise the pen-up travel. +- [`crop`](https://vpype.readthedocs.io/en/latest/reference.html#crop), well, crops. +- [`write`](https://vpype.readthedocs.io/en/latest/reference.html#write) export the resulting geometries to a SVG file. There are many more commands available in *vpype*, see the [overview](#feature-overview) below. Some commands have arguments, which are always required (in italic). For example, a file path must be provided to the -[`read`](https://vpype.readthedocs.io/en/stable/reference.html#read) command and dimensions must be provided to the [`crop`](https://vpype.readthedocs.io/en/stable/reference.html#crop) commands. A command may also have options which are, well, -optional. In this example, `--page-size a4` means that the [`write`](https://vpype.readthedocs.io/en/stable/reference.html#write) command will generate a A4-sized SVG (otherwise it -would have the same size as _in.svg_). Likewise, because `--center` is used, the [`write`](https://vpype.readthedocs.io/en/stable/reference.html#write) command will center geometries +[`read`](https://vpype.readthedocs.io/en/latest/reference.html#read) command and dimensions must be provided to the [`crop`](https://vpype.readthedocs.io/en/latest/reference.html#crop) commands. A command may also have options which are, well, +optional. In this example, `--page-size a4` means that the [`write`](https://vpype.readthedocs.io/en/latest/reference.html#write) command will generate a A4-sized SVG (otherwise it +would have the same size as _in.svg_). Likewise, because `--center` is used, the [`write`](https://vpype.readthedocs.io/en/latest/reference.html#write) command will center geometries on the page before saving the SVG (otherwise the geometries would have been left at their original location). ## Examples -**Note**: although it is not required, commands are separated by multiple spaces for clarity in the following examples. +**Note**: The following examples are laid out over multiple lines using end-of-line escaping (`\`). This is done to highlight the various commands of which the pipeline is made and would typically not be done in real-world use. Load an SVG file, scale it to a specific size, and export it centered on an A4-sized, ready-to-plot SVG file: -``` -vpype read input.svg scaleto 10cm 10cm write --page-size a4 --center output.svg +```bash +$ vpype \ + read input.svg \ + layout --fit-to-margins 2cm a4 \ + write output.svg ``` -Optimize paths to reduce plotting time (merge connected lines and sort them to minimize pen-up distance): -``` -vpype read input.svg linemerge --tolerance 0.1mm linesort write output.svg +Optimize paths to reduce plotting time (merge connected lines, sort them to minimize pen-up distance, randomize closed paths' seam, and reduce the number of nodes): +```bash +$ vpype \ + read input.svg \ + linemerge --tolerance 0.1mm \ + linesort \ + reloop \ + linesimplify \ + write output.svg ``` -Visualize the path structure of large SVG files, showing whether lines are properly joined or not thanks to a colorful -display: -``` -vpype read input.svg show --colorful +Load a SVG and display it in *vpype*'s viewer, which enable close inspection of the layer and path structure): +```bash +$ vpype \ + read input.svg \ + show ``` Load several SVG files and save them as a single, multi-layer SVG file (e.g. for multicolored drawings): +```bash +$ vpype \ + forfile "*.svg" \ + read --layer %_i% %_path% \ + end \ + write output.svg ``` -vpype read -l 1 input1.svg read -l 2 input2.svg write output.svg + +Export a SVG to HPGL for vintage plotters: +```bash +$ vpype \ + read input.svg \ + layout --fit-to-margins 2cm --landscape a4 \ + write --device hp7475a output.hpgl ``` -Create arbitrarily-sized, grid-like designs like this page's top banner: +Draw the layer name on a SVG (this example uses [property substitution](https://vpype.readthedocs.io/en/latest/fundamentals.html#property-substitution)): +```bash +$ vpype \ + read input.svg \ + text --layer 1 "{vp_name}" \ + write output.svg ``` -vpype begin grid -o 1cm 1cm 10 13 script alien_letter.py scaleto 0.5cm 0.5cm end show + +Merge multiple SVG files in a grid layout (this example uses [expression substitution](https://vpype.readthedocs.io/en/latest/fundamentals.html#expression-substitution)): +```bash +$ vpype \ + eval "files=glob('*.svg')" \ + eval "cols=3; rows=ceil(len(files)/cols)" \ + grid -o 10cm 10cm "%cols%" "%rows%" \ + read --no-fail "%files[_i] if _i < len(files) else ''%" \ + layout -m 0.5cm 10x10cm \ + end \ + write combined_on_a_grid.svg ``` -Export to HPGL for vintage plotters: +An interactive version of the previous example is available in `examples/grid.vpy`. It makes use of `input()` expressions to ask parameters from the user: +```bash +$ vpype -I examples/grid.vpy +Files [*.svg]? +Number of columns [3]? 4 +Column width [10cm]? +Row height [10cm]? 15cm +Margin [0.5cm]? +Output path [output.svg]? ``` -vpype read input.svg write --device hp7475a --page-size a4 --landscape --center output.hpgl + +Split a SVG into one file per layer: +```bash +$ vpype \ + read input.svg \ + forlayer \ + write "output_%_name or _lid%.svg" \ + end ``` + +More examples and recipes are available in the [cookbook](https://vpype.readthedocs.io/en/latest/cookbook.html). ## What _vpype_ isn't? _vpype_ caters to plotter generative art and does not aim to be a general purpose (think Illustrator/InkScape) vector graphic tools. One of the main reason for this is the fact _vpype_ converts everything -curvy (circles, bezier curves, etc.) to lines made of small segments. _vpype_ also dismisses the stroke and fill -properties (color, line width, etc.) of the imported graphics. These design choices make possible _vpype_'s rich feature -set, but makes its use for, e.g., printed media limited. +curvy (circles, bezier curves, etc.) to lines made of small segments. _vpype_ does import metadata such stroke and fill color, stroke width, etc., it only makes partial use of them and does not aim to maintain a full consistency with the SVG specification. These design choices make _vpype_'s rich feature set possible, but limits its use for, e.g., printed media. ## Installation @@ -148,7 +200,7 @@ when using this installation method). ```bash pip install vpype ``` - This version does not include the [`show`](https://vpype.readthedocs.io/en/stable/reference.html#show) command but does not require some of the dependencies which are more difficult or impossible to install on some platforms (such as matplotlib, PySide2, and ModernGL). + This version does not include the [`show`](https://vpype.readthedocs.io/en/latest/reference.html#show) command but does not require some of the dependencies which are more difficult or impossible to install on some platforms (such as matplotlib, PySide2, and ModernGL). ## Documentation @@ -160,7 +212,7 @@ vpype --help # general help and command list vpype COMMAND --help # help for a specific command ``` -In addition, the [online documentation](https://vpype.readthedocs.io/en/stable/) provides extensive background +In addition, the [online documentation](https://vpype.readthedocs.io/en/latest/) provides extensive background information on the fundamentals behind _vpype_, a cookbook covering most common tasks, the _vpype_ API documentation, and much more. @@ -169,66 +221,70 @@ and much more. #### General -- Easy to use **CLI** interface with integrated help (`vpype --help`and `vpype COMMAND --help`) and support for arbitrary units (e.g. `vpype read input.svg translate 3cm 2in`). -- First-class **multi-layer support** with global or per-layer processing (e.g. `vpype COMMANDNAME --layer 1,3`) and optionally-probabilistic layer edition commands ([`lmove`](https://vpype.readthedocs.io/en/stable/reference.html#lmove), [`lcopy`](https://vpype.readthedocs.io/en/stable/reference.html#lcopy), [`ldelete`](https://vpype.readthedocs.io/en/stable/reference.html#ldelete), [`lswap`](https://vpype.readthedocs.io/en/stable/reference.html#lswap), [`lreverse`](https://vpype.readthedocs.io/en/stable/reference.html#lreverse)). -- Powerful hardware-accelerated **display** command with adjustable units, optional per-line coloring, optional pen-up trajectories display and per-layer visibility control ([`show`](https://vpype.readthedocs.io/en/stable/reference.html#show)). -- Geometry **statistics** extraction ([`stat`](https://vpype.readthedocs.io/en/stable/reference.html#stat)). +- Easy to use **CLI** interface with integrated help (`vpype --help`and `vpype COMMAND --help`) and support for arbitrary units (e.g. `vpype read input.svg translate 3cm 2in`). +- First-class **multi-layer support** with global or per-layer processing (e.g. `vpype COMMANDNAME --layer 1,3`) and optionally-probabilistic layer edition commands ([`lmove`](https://vpype.readthedocs.io/en/latest/reference.html#lmove), [`lcopy`](https://vpype.readthedocs.io/en/latest/reference.html#lcopy), [`ldelete`](https://vpype.readthedocs.io/en/latest/reference.html#ldelete), [`lswap`](https://vpype.readthedocs.io/en/latest/reference.html#lswap), [`lreverse`](https://vpype.readthedocs.io/en/latest/reference.html#lreverse)). +- Support for **per-layer and global properties**, which acts as metadata and is used by multiple commands and plug-ins. +- Support for [**property**](https://vpype.readthedocs.io/en/latest/fundamentals.html#property-substitution) and [**expression substitution**](https://vpype.readthedocs.io/en/latest/fundamentals.html#expression-substitution) in CLI user input. +- Support for complex, **per-layer** processing ([`perlayer`](https://vpype.readthedocs.io/en/latest/reference.html#perlayer)). +- Powerful hardware-accelerated **display** command with adjustable units, optional per-line coloring, optional pen-up trajectories display and per-layer visibility control ([`show`](https://vpype.readthedocs.io/en/latest/reference.html#show)). +- Geometry **statistics** extraction ([`stat`](https://vpype.readthedocs.io/en/latest/reference.html#stat)). - Support for **command history** recording (`vpype -H [...]`) - Support for **RNG seed** configuration for generative plug-ins (`vpype -s 37 [...]`). #### Input/Output -- Single- and multi-layer **SVG input** with adjustable precision, parallel processing for large SVGs, and supports percent or missing width/height ([`read`](https://vpype.readthedocs.io/en/stable/reference.html#read)). -- Support for **SVG output** with fine layout control (page size and orientation, centering), layer support with custom layer names, optional display of pen-up trajectories, various option for coloring ([`write`](https://vpype.readthedocs.io/en/stable/reference.html#write)). +- Single- and multi-layer **SVG input** with adjustable precision, parallel processing for large SVGs, and supports percent or missing width/height ([`read`](https://vpype.readthedocs.io/en/latest/reference.html#read)). +- Support for **SVG output** with fine layout control (page size and orientation, centering), layer support with custom layer names, optional display of pen-up trajectories, various option for coloring ([`write`](https://vpype.readthedocs.io/en/latest/reference.html#write)). - Support for **HPGL output** config-based generation of HPGL code with fine layout control (page size and orientation, centering). +- Support for pattern-based **file collection** processing ([`forfile`](https://vpype.readthedocs.io/en/latest/reference.html#forfile)). #### Layout and transforms - Easy and flexible **layout** command for centring and fitting to margin with selectable le horizontal and vertical alignment - ([`layout`](https://vpype.readthedocs.io/en/stable/reference.html#layout)). -- Powerful **transform** commands for scaling, translating, skewing and rotating geometries ([`scale`](https://vpype.readthedocs.io/en/stable/reference.html#scale), [`translate`](https://vpype.readthedocs.io/en/stable/reference.html#translate), [`skew`](https://vpype.readthedocs.io/en/stable/reference.html#skew), [`rotate`](https://vpype.readthedocs.io/en/stable/reference.html#rotate)). -- Support for **scaling** and **cropping** to arbitrary dimensions ([`scaleto`](https://vpype.readthedocs.io/en/stable/reference.html#scaleto), [`crop`](https://vpype.readthedocs.io/en/stable/reference.html#crop)). -- Support for **trimming** geometries by an arbitrary amount ([`trim`](https://vpype.readthedocs.io/en/stable/reference.html#trim)). -- Arbitrary **page size** definition ([`pagesize`](https://vpype.readthedocs.io/en/stable/reference.html#pagesize)). + ([`layout`](https://vpype.readthedocs.io/en/latest/reference.html#layout)). +- Powerful **transform** commands for scaling, translating, skewing and rotating geometries ([`scale`](https://vpype.readthedocs.io/en/latest/reference.html#scale), [`translate`](https://vpype.readthedocs.io/en/latest/reference.html#translate), [`skew`](https://vpype.readthedocs.io/en/latest/reference.html#skew), [`rotate`](https://vpype.readthedocs.io/en/latest/reference.html#rotate)). +- Support for **scaling** and **cropping** to arbitrary dimensions ([`scaleto`](https://vpype.readthedocs.io/en/latest/reference.html#scaleto), [`crop`](https://vpype.readthedocs.io/en/latest/reference.html#crop)). +- Support for **trimming** geometries by an arbitrary amount ([`trim`](https://vpype.readthedocs.io/en/latest/reference.html#trim)). +- Arbitrary **page size** definition ([`pagesize`](https://vpype.readthedocs.io/en/latest/reference.html#pagesize)). #### Metadata -- Adjust layer **color**, **pen width** and **name** ([`color`](https://vpype.readthedocs.io/en/stable/reference.html#color), [`penwidth`](https://vpype.readthedocs.io/en/stable/reference.html#penwidth), [`name`](https://vpype.readthedocs.io/en/stable/reference.html#name)). -- Apply provided or fully customisable **pen configurations** ([`pens`](https://vpype.readthedocs.io/en/stable/reference.html#pens)). -- Manipulate global and per-layer **properties** ([`propset`](https://vpype.readthedocs.io/en/stable/reference.html#propset), [`propget`](https://vpype.readthedocs.io/en/stable/reference.html#propget), [`proplist`](https://vpype.readthedocs.io/en/stable/reference.html#proplist), [`propdel`](https://vpype.readthedocs.io/en/stable/reference.html#propdel), [`propclear`](https://vpype.readthedocs.io/en/stable/reference.html#propclear)). +- Adjust layer **color**, **pen width** and **name** ([`color`](https://vpype.readthedocs.io/en/latest/reference.html#color), [`penwidth`](https://vpype.readthedocs.io/en/latest/reference.html#penwidth), [`name`](https://vpype.readthedocs.io/en/latest/reference.html#name)). +- Apply provided or fully customisable **pen configurations** ([`pens`](https://vpype.readthedocs.io/en/latest/reference.html#pens)). +- Manipulate global and per-layer **properties** ([`propset`](https://vpype.readthedocs.io/en/latest/reference.html#propset), [`propget`](https://vpype.readthedocs.io/en/latest/reference.html#propget), [`proplist`](https://vpype.readthedocs.io/en/latest/reference.html#proplist), [`propdel`](https://vpype.readthedocs.io/en/latest/reference.html#propdel), [`propclear`](https://vpype.readthedocs.io/en/latest/reference.html#propclear)). #### Plotting optimization -- **Line merging** with optional path reversal and configurable merging threshold ([`linemerge`](https://vpype.readthedocs.io/en/stable/reference.html#linemerge)). -- **Line sorting** with optional path reversal ([`linesort`](https://vpype.readthedocs.io/en/stable/reference.html#linesort)). -- **Line simplification** with adjustable accuracy ([`linesimplify`](https://vpype.readthedocs.io/en/stable/reference.html#linesimplify)). -- Closed paths' **seam location randomization**, to reduce the visibility of pen-up/pen-down artifacts ([`reloop`](https://vpype.readthedocs.io/en/stable/reference.html#reloop)). -- Support for generating **multiple passes** on each line ([`multipass`](https://vpype.readthedocs.io/en/stable/reference.html#multipass)). +- **Line merging** with optional path reversal and configurable merging threshold ([`linemerge`](https://vpype.readthedocs.io/en/latest/reference.html#linemerge)). +- **Line sorting** with optional path reversal ([`linesort`](https://vpype.readthedocs.io/en/latest/reference.html#linesort)). +- **Line simplification** with adjustable accuracy ([`linesimplify`](https://vpype.readthedocs.io/en/latest/reference.html#linesimplify)). +- Closed paths' **seam location randomization**, to reduce the visibility of pen-up/pen-down artifacts ([`reloop`](https://vpype.readthedocs.io/en/latest/reference.html#reloop)). +- Support for generating **multiple passes** on each line ([`multipass`](https://vpype.readthedocs.io/en/latest/reference.html#multipass)). #### Filters -- Support for **filtering** by line lengths or closed-ness ([`filter`](https://vpype.readthedocs.io/en/stable/reference.html#filter)). +- Support for **filtering** by line lengths or closed-ness ([`filter`](https://vpype.readthedocs.io/en/latest/reference.html#filter)). - **Squiggle** filter for shaky-hand or liquid-like styling ([`squiggles`](https://vpype.readthedocs.io/en/latest/reference.html#squiggles)) -- Support for **splitting** all lines to their constituent segments ([`splitall`](https://vpype.readthedocs.io/en/stable/reference.html#splitall)). +- Support for **splitting** all lines to their constituent segments ([`splitall`](https://vpype.readthedocs.io/en/latest/reference.html#splitall)). - Support for **reversing** order of paths within their layers ([`reverse`](https://vpype.readthedocs.io/en/latest/reference.html#reverse)). #### Generation - - Generation of arbitrary **primitives** including lines, rectangles, circles, ellipses and arcs ([`line`](https://vpype.readthedocs.io/en/stable/reference.html#line), [`rect`](https://vpype.readthedocs.io/en/stable/reference.html#rect), [`circle`](https://vpype.readthedocs.io/en/stable/reference.html#circle), [`ellipse`](https://vpype.readthedocs.io/en/stable/reference.html#ellipse), [`arc`](https://vpype.readthedocs.io/en/stable/reference.html#arc)). + - Generation of arbitrary **primitives** including lines, rectangles, circles, ellipses and arcs ([`line`](https://vpype.readthedocs.io/en/latest/reference.html#line), [`rect`](https://vpype.readthedocs.io/en/latest/reference.html#rect), [`circle`](https://vpype.readthedocs.io/en/latest/reference.html#circle), [`ellipse`](https://vpype.readthedocs.io/en/latest/reference.html#ellipse), [`arc`](https://vpype.readthedocs.io/en/latest/reference.html#arc)). - Generation of **text** using bundled Hershey fonts ([`text`](https://vpype.readthedocs.io/en/latest/reference.html#text)) - - Generation of grid-like layouts ([`grid`](https://vpype.readthedocs.io/en/stable/reference.html#grid)). - - Generation of a **frame** around the geometries ([`frame`](https://vpype.readthedocs.io/en/stable/reference.html#frame)). - - Generation of random lines for debug/learning purposes ([`random`](https://vpype.readthedocs.io/en/stable/reference.html#random)) + - Generation of grid-like layouts ([`grid`](https://vpype.readthedocs.io/en/latest/reference.html#grid)). + - Generation of a **frame** around the geometries ([`frame`](https://vpype.readthedocs.io/en/latest/reference.html#frame)). + - Generation of random lines for debug/learning purposes ([`random`](https://vpype.readthedocs.io/en/latest/reference.html#random)) #### Extensibility and API - First-class support for **plug-in** extensions (e.g [vpype-text](https://github.com/abey79/vpype-text), [hatched](https://github.com/abey79/hatched), [occult](https://github.com/LoicGoulefert/occult)). - - Support for **script-based** generation ([`script`](https://vpype.readthedocs.io/en/stable/reference.html#script)). - - Powerful and [well-documented](https://vpype.readthedocs.io/en/stable/api.html) **API** for plug-ins and other plotter generative art projects. + - Support for **script-based** generation ([`script`](https://vpype.readthedocs.io/en/latest/reference.html#script)). + - Powerful and [well-documented](https://vpype.readthedocs.io/en/latest/api.html) **API** for plug-ins and other plotter generative art projects. ## Plug-ins @@ -250,7 +306,7 @@ and much more. ## Contributing Contributions to this project are welcome and do not necessarily require software development skills! Check the -[Contributing section](https://vpype.readthedocs.io/en/stable/contributing.html) of the documentation for more +[Contributing section](https://vpype.readthedocs.io/en/latest/contributing.html) of the documentation for more information. diff --git a/docs/fundamentals.rst b/docs/fundamentals.rst index 5bddfba2..ce77f0fb 100644 --- a/docs/fundamentals.rst +++ b/docs/fundamentals.rst @@ -158,10 +158,10 @@ Likewise, angles are interpreted as degrees by default but alternative units may .. _fundamentals_metadata: -Metadata -======== +Properties +========== -Metadata is data which provides information about other data. In the case of *vpype*, metadata takes the form of *properties* that are either attached to a given layer, or global. Properties are identified by a name and their value can be of arbitrary type (e.g. integer, floating point, color, etc.). There can be any number of global and/or layer properties and it is up to commands (and plug-ins) how they act based (or upon) these properties. +In addition to geometries, the *vpype* pipeline carries metadata, i.e. data that provides information about geometries. This metadata takes the form of *properties* that are either attached to a given layer, or global. Properties are identified by a name and their value can be of arbitrary type (e.g. integer, floating point, color, etc.). There can be any number of global and/or layer properties and it is up to commands (and plug-ins) how they act based (or upon) these properties. System properties @@ -201,8 +201,8 @@ High-level commands such as :ref:`cmd_penwidth` are not the only means of intera .. _fundamentals_property_substitution: -CLI property substitution -------------------------- +Property substitution +--------------------- Most arguments and options passes to commands via the *vpype* CLI will apply property substitution on the provided input. For example, this command will draw the name of the layer:: diff --git a/examples/grid.vpy b/examples/grid.vpy new file mode 100644 index 00000000..76c60d74 --- /dev/null +++ b/examples/grid.vpy @@ -0,0 +1,21 @@ +# Ask user for some information, using sensible defaults. +eval "files=glob(input('Files [*.svg]? ') or '*.svg')" # glob() creates a list of file based on a pattern +eval "cols=int(input('Number of columns [3]? ') or 3)" +eval "rows=ceil(len(files)/cols)" # the number of rows depends on the number of files +eval "col_width=convert_length(input('Column width [10cm]? ') or '10cm')" # convert_length() converts string like '3cm' to pixels +eval "row_height=convert_length(input('Row height [10cm]? ') or '10cm')" +eval "margin=convert_length(input('Margin [0.5cm]? ') or '0.5cm')" +eval "output_path=input('Output path [output.svg]? ') or 'output.svg'" + +# Create a grid with provided parameters. +grid -o %col_width% %row_height% %cols% %rows% + + # Read the `_i`-th file. The last row may be incomplete so we use an empty path and `--no-fail`. + read --no-fail "%files[_i] if _i < len(files) else ''%" + + # Layout the file in the cell. + layout -m %margin% %col_width%x%row_height% +end + +# wWrite the output file. +write "%output_path%" \ No newline at end of file diff --git a/tests/test_commands.py b/tests/test_commands.py index b1d2f7fb..5673380c 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -23,13 +23,15 @@ class Command: exit_code_one_layer: int = 0 exit_code_two_layers: int = 0 preserves_metadata: bool = True + keeps_page_size: bool = True MINIMAL_COMMANDS = [ - Command("begin grid 2 2 line 0 0 10 10 end"), + Command("begin grid 2 2 line 0 0 10 10 end", keeps_page_size=False), + Command("begin grid 0 0 line 0 0 10 10 end"), # doesn't update page size Command("begin repeat 2 line 0 0 10 10 end"), - Command("grid 2 2 line 0 0 10 10 end"), # implicit `begin` - Command("grid 2 2 repeat 2 random -n 1 end end"), # nested block + Command("grid 2 2 line 0 0 10 10 end", keeps_page_size=False), # implicit `begin` + Command("grid 2 2 repeat 2 random -n 1 end end", keeps_page_size=False), # nested block Command("frame"), Command("random"), Command("line 0 0 1 1"), @@ -70,11 +72,11 @@ class Command: Command("trim 1mm 1mm"), Command("splitall"), Command("filter --min-length 1mm"), - Command("pagesize 10inx15in"), + Command("pagesize 10inx15in", keeps_page_size=False), Command("stat"), Command("snap 1"), Command("reverse"), - Command("layout a4"), + Command("layout a4", keeps_page_size=False), Command("squiggles"), Command("text 'hello wold'"), Command("penwidth 0.15mm", preserves_metadata=False), @@ -88,7 +90,7 @@ class Command: Command("proplist -l 1"), Command("propdel -g prop:global", preserves_metadata=False), Command("propdel -l 1 prop:layer", preserves_metadata=False), - Command("propclear -g", preserves_metadata=False), + Command("propclear -g", preserves_metadata=False, keeps_page_size=False), Command("propclear -l 1", preserves_metadata=False), Command( f"forfile '{EXAMPLE_SVG_DIR / '*.svg'}' text -p 0 %_i*cm% '%_i%/%_n%: %_name%' end" @@ -153,7 +155,7 @@ def test_commands_keeps_page_size(runner, cmd): args = cmd.command - if args.split()[0] in ["pagesize", "layout"] or args.startswith("propclear -g"): + if not cmd.keeps_page_size: pytest.skip(f"command {args.split()[0]} fail this test by design") page_size = None diff --git a/tests/test_files.py b/tests/test_files.py index d809a6cf..69455a49 100644 --- a/tests/test_files.py +++ b/tests/test_files.py @@ -4,6 +4,7 @@ import re from typing import Set +import click import numpy as np import pytest @@ -423,3 +424,9 @@ def test_write_svg_svg_props_unknown_namespace(capsys): "line 0 0 10 10 layout a5 propset -g svg_unknown_version '1.1.0' write -r -f svg -" ) assert 'unknown:version="1.1.0"' not in capsys.readouterr().out + + +def test_read_no_fail(): + with pytest.raises(click.BadParameter): + vpype_cli.execute("read doesnotexist.svg") + vpype_cli.execute("read --no-fail doesnotexist.svg") diff --git a/vpype_cli/blocks.py b/vpype_cli/blocks.py index 4b3662eb..5b73b534 100644 --- a/vpype_cli/blocks.py +++ b/vpype_cli/blocks.py @@ -80,6 +80,8 @@ def grid( execute_processors(processors, state) doc.translate(offset[0] * i, offset[1] * j) state.document.extend(doc) + if nx > 0 and ny > 0: + state.document.page_size = (nx * offset[0], ny * offset[1]) return state diff --git a/vpype_cli/decorators.py b/vpype_cli/decorators.py index 04e018f2..a03005e1 100644 --- a/vpype_cli/decorators.py +++ b/vpype_cli/decorators.py @@ -78,16 +78,17 @@ def new_func(*args, **kwargs): # noinspection PyShadowingNames def layer_processor(state: State) -> State: - for lid in multiple_to_layer_ids(layers, state.document): - logging.info( - f"executing layer processor `{f.__name__}` on layer {lid} " - f"(kwargs: {kwargs})" - ) + layers_eval = state.preprocess_argument(layers) + for lid in multiple_to_layer_ids(layers_eval, state.document): start = datetime.datetime.now() with state.current(): state.current_layer_id = lid new_args, new_kwargs = state.preprocess_arguments(args, kwargs) + logging.info( + f"executing layer processor `{f.__name__}` on layer {lid} " + f"(kwargs: {new_kwargs})" + ) state.document[lid] = f(state.document[lid], *new_args, **new_kwargs) state.current_layer_id = None stop = datetime.datetime.now() @@ -148,11 +149,12 @@ def my_global_processor( def new_func(*args, **kwargs): # noinspection PyShadowingNames def global_processor(state: State) -> State: - logging.info(f"executing global processor `{f.__name__}` (kwargs: {kwargs})") - start = datetime.datetime.now() with state.current(): new_args, new_kwargs = state.preprocess_arguments(args, kwargs) + logging.info( + f"executing global processor `{f.__name__}` (kwargs: {new_kwargs})" + ) state.document = f(state.document, *new_args, **new_kwargs) stop = datetime.datetime.now() @@ -189,16 +191,16 @@ def new_func(*args, **kwargs): # noinspection PyShadowingNames def generator(state: State) -> State: with state.current(): - target_layer = single_to_layer_id(layer, state.document) - - logging.info( - f"executing generator `{f.__name__}` to layer {target_layer} " - f"(kwargs: {kwargs})" - ) + layer_eval = state.preprocess_argument(layer) + target_layer = single_to_layer_id(layer_eval, state.document) start = datetime.datetime.now() state.current_layer_id = target_layer new_args, new_kwargs = state.preprocess_arguments(args, kwargs) + logging.info( + f"executing generator `{f.__name__}` to layer {target_layer} " + f"(kwargs: {new_kwargs})" + ) state.document.add(f(*new_args, **new_kwargs), target_layer) state.current_layer_id = None stop = datetime.datetime.now() @@ -248,6 +250,7 @@ def block_processor(state: State, processors: Iterable["ProcessorType"]) -> Stat start = datetime.datetime.now() new_args, new_kwargs = state.preprocess_arguments(args, kwargs) + logging.info(f"executing block processor `{f.__name__}` (kwargs: {new_kwargs})") f(state, processors, *new_args, **new_kwargs) stop = datetime.datetime.now() diff --git a/vpype_cli/read.py b/vpype_cli/read.py index 5a33e735..8b6a5012 100644 --- a/vpype_cli/read.py +++ b/vpype_cli/read.py @@ -1,4 +1,5 @@ import logging +import pathlib import sys from typing import List, Optional, Tuple @@ -14,7 +15,7 @@ @cli.command(group="Input") -@click.argument("file", type=PathType(exists=True, dir_okay=False, allow_dash=True)) +@click.argument("file", type=PathType(dir_okay=False, allow_dash=True)) @click.option("-m", "--single-layer", is_flag=True, help="Single layer mode.") @click.option( "-l", @@ -36,6 +37,7 @@ default="0.1mm", help="Maximum length of segments approximating curved elements (default: 0.1mm).", ) +@click.option("--no-fail", is_flag=True, help="Do not fail is the target file doesn't exist.") @click.option( "-s", "--simplify", @@ -82,6 +84,7 @@ def read( layer: Optional[int], attr: List[str], quantization: float, + no_fail: bool, simplify: bool, parallel: bool, no_crop: bool, @@ -188,6 +191,12 @@ def read( if file == "-": file = sys.stdin + elif not pathlib.Path(file).is_file(): + if no_fail: + logging.debug("read: file doesn't exist, ignoring due to `--no-fail`") + return document + else: + raise click.BadParameter(f"file {file!r} does not exist") if layer is not None and not single_layer: single_layer = True diff --git a/vpype_cli/state.py b/vpype_cli/state.py index 5b65228c..76cae43e 100644 --- a/vpype_cli/state.py +++ b/vpype_cli/state.py @@ -29,8 +29,9 @@ class _DeferredEvaluator(ABC): instances, perform the conversion, and forward the converted value to the command function. """ - def __init__(self, text: str): + def __init__(self, text: str, param_name: str, *args, **kwargs): self._text = text + self._param_name = param_name @abstractmethod def evaluate(self, state: "State") -> Any: @@ -71,21 +72,32 @@ def __init__(self, document: Optional[vp.Document] = None): self._interpreter = SubstitutionHelper(self) - def _preprocess_arg(self, arg: Any) -> Any: + def preprocess_argument(self, arg: Any) -> Any: + """Evaluate an argument. + + If ``arg`` is a :class:`_DeferredEvaluator` instance, evaluate it a return its value + instead. + + Args: + arg: argument to evaluate + + Returns: + returns the fully evaluated ``arg`` + """ if isinstance(arg, tuple): - return tuple(self._preprocess_arg(item) for item in arg) + return tuple(self.preprocess_argument(item) for item in arg) else: return arg.evaluate(self) if isinstance(arg, _DeferredEvaluator) else arg def preprocess_arguments( self, args: Tuple[Any, ...], kwargs: Dict[str, Any] ) -> Tuple[Tuple[Any, ...], Dict[str, Any]]: - """Replace any instance of :class:`_DeferredEvaluator` and replace them with the + """Evaluate any instance of :class:`_DeferredEvaluator` and replace them with the converted value. """ return ( - tuple(self._preprocess_arg(arg) for arg in args), - {k: self._preprocess_arg(v) for k, v in kwargs.items()}, + tuple(self.preprocess_argument(arg) for arg in args), + {k: self.preprocess_argument(v) for k, v in kwargs.items()}, ) def substitute(self, text: str) -> str: diff --git a/vpype_cli/substitution.py b/vpype_cli/substitution.py index 9e8a61d4..e8430ccf 100644 --- a/vpype_cli/substitution.py +++ b/vpype_cli/substitution.py @@ -1,6 +1,8 @@ +import glob import os +import pathlib import sys -from typing import TYPE_CHECKING, Any, Callable, Dict, Iterable, Optional +from typing import TYPE_CHECKING, Any, Callable, Dict, Iterable, List, Optional import asteval @@ -205,6 +207,12 @@ def _substitute_expressions( return "".join(_split_text(text, prop_interpreter, expr_interpreter)) +def _glob(files: str) -> List[pathlib.Path]: + return [ + pathlib.Path(file) for file in glob.glob(os.path.expandvars(os.path.expanduser(files))) + ] + + _OS_PATH_SYMBOLS = ( "abspath", "basename", @@ -231,14 +239,19 @@ def __init__(self, state: "State"): **vp.UNITS, **{f: getattr(os.path, f) for f in _OS_PATH_SYMBOLS}, "input": input, + "glob": _glob, + "convert_length": vp.convert_length, + "convert_angle": vp.convert_angle, + "convert_page_size": vp.convert_page_size, "stdin": sys.stdin, "Color": vp.Color, "prop": self._property_proxy, "lprop": _PropertyProxy(state, False, True), "gprop": _PropertyProxy(state, True, False), } + # disabling numpy as its math functions such as `ceil` do not convert to int. self._interpreter = asteval.Interpreter( - usersyms=symtable, readonly_symbols=symtable.keys() + usersyms=symtable, readonly_symbols=symtable.keys(), use_numpy=False ) @property diff --git a/vpype_cli/types.py b/vpype_cli/types.py index c57930c8..eb740370 100644 --- a/vpype_cli/types.py +++ b/vpype_cli/types.py @@ -16,9 +16,15 @@ class _DeferredEvaluatorType(click.ParamType): _evaluator_class: ClassVar = _DeferredEvaluator + def __init__(self, *args, **kwargs): + self._args = args + self._kwargs = kwargs + def convert(self, value, param, ctx): if isinstance(value, str): - return self.__class__._evaluator_class(value) + return self.__class__._evaluator_class( + value, param.human_readable_name, *self._args, **self._kwargs + ) else: return super().convert(value, param, ctx) @@ -164,8 +170,8 @@ class _DelegatedDeferredEvaluatorType(click.ParamType): """ class _DelegatedDeferredEvaluator(_DeferredEvaluator): - def __init__(self, text: str, cls: Type, *args, **kwargs): - super().__init__(text) + def __init__(self, text: str, param_name: str, cls: Type, *args, **kwargs): + super().__init__(text, param_name) self._cls = cls self._args = args self._kwargs = kwargs @@ -181,7 +187,11 @@ def __init__(self, *args, **kwargs): def convert(self, value, param, ctx): if isinstance(value, str): return self.__class__._DelegatedDeferredEvaluator( - value, self.__class__._delegate_class, *self._args, **self._kwargs + value, + param.human_readable_name, + self.__class__._delegate_class, + *self._args, + **self._kwargs, ) else: return super().convert(value, param, ctx) @@ -249,6 +259,8 @@ def single_to_layer_id( lid = document.free_id() elif layer is None: lid = State.get_current().target_layer_id + elif layer < 1: + raise click.BadParameter(f"layer {layer} is invalid") else: lid = layer @@ -258,7 +270,7 @@ def single_to_layer_id( return lid -class LayerType(click.ParamType): +class LayerType(_DeferredEvaluatorType): """Interpret values of --layer options. If `accept_multiple == True`, comma-separated array of int is accepted or 'all'. Returns @@ -269,64 +281,57 @@ class LayerType(click.ParamType): None is passed through, which typically means to use the default behaviour. """ - name = "layer ID" - NEW = -1 ALL = -2 - def __init__(self, accept_multiple: bool = False, accept_new: bool = False): - self.accept_multiple = accept_multiple - self.accept_new = accept_new - - if accept_multiple: - self.name = "layers" - else: - self.name = "layer" - - def convert(self, value, param, ctx): - # accept value when already converted to final type - if isinstance(value, int): - if value > 0 or value in [self.ALL, self.NEW]: - return value - else: - self.fail(f"inconsistent converted value {value}") - - value = str(value) - if value.lower() == "all": - if self.accept_multiple: - return LayerType.ALL - else: - self.fail( - f"parameter {param.human_readable_name} must be a single layer and does " - "not accept `all`", - param, - ctx, + class _LayerTypeDeferredEvaluator(_DeferredEvaluator): + def __init__( + self, text: str, param_name: str, accept_multiple: bool, accept_new: bool + ): + super().__init__(text, param_name) + self._accept_multiple = accept_multiple + self._accept_new = accept_new + + def evaluate(self, state: "State") -> Union[int, List[int]]: + value = state.substitute(self._text) + + if value.lower() == "all": + if self._accept_multiple: + return LayerType.ALL + else: + raise click.BadParameter( + f"parameter {self._param_name} must be a single layer and does " + "not accept `all`" + ) + elif value.lower() == "new": + if self._accept_new: + return LayerType.NEW + else: + raise click.BadParameter( + f"parameter {self._param_name} must be an existing layer and " + "does not accept `new`" + ) + + try: + if self._accept_multiple: + id_arr = list(map(int, value.split(","))) + for i in id_arr: + if i < 1: + raise click.BadParameter(f"layer {i} is invalid") + return id_arr + else: + return int(value) + except TypeError: + raise click.BadParameter( + f"unexpected {value!r} of type {type(value).__name__}" ) - elif value.lower() == "new": - if self.accept_new: - return LayerType.NEW - else: - self.fail( - f"parameter {param.human_readable_name} must be an existing layer and " - "does not accept `new`", - param, - ctx, + except ValueError: + raise click.BadParameter( + f"{value!r} is not a valid value for parameter {self._param_name}" ) - try: - if self.accept_multiple: - id_arr = list(map(int, value.split(","))) - for i in id_arr: - if i < 1: - raise TypeError - return id_arr - else: - return int(value) - except TypeError: - self.fail(f"unexpected {value!r} of type {type(value).__name__}", param, ctx) - except ValueError: - self.fail( - f"{value!r} is not a valid value for parameter {param.human_readable_name}", - param, - ctx, - ) + def __init__(self, accept_multiple: bool = False, accept_new: bool = False): + super().__init__(accept_multiple=accept_multiple, accept_new=accept_new) + + name = "lid" + _evaluator_class = _LayerTypeDeferredEvaluator