diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bfdc678..fed2d95c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,12 @@ New features and improvements: * A Windows installer is now available (#120) +* HPGL output: `--page-size` is no longer mandatory and `write` will try to infer which paper to use based on the current page size (#132) * Added `reverse` command (#129) Bug fixes: * Fixed crash for SVG with element (#127) +* Fixed an issue where output HPGL file could be empty (#132) #### 1.1 (2020-12-10) diff --git a/docs/cookbook.rst b/docs/cookbook.rst index 01be1da5..5f535548 100644 --- a/docs/cookbook.rst +++ b/docs/cookbook.rst @@ -90,8 +90,8 @@ file:: $ vpype read --single-layer --layer 1 input1.svg read --single-layer --layer 2 input2.svg write output.svg -Note the use of ``--single-layer``. It is necessary to make sure that the input SVG is merged into a single layer and is -necessary to enable the ``--layer`` option. +Note the use of :option:`--single-layer `. It is necessary to make sure that the input SVG is +merged into a single layer and is necessary to enable the :option:`--layer ` option. This command will :ref:`cmd_read` two SVG files onto two different layers, rotate one layer 180 degrees, then :ref:`cmd_write` both layers into a single SVG file:: @@ -120,13 +120,20 @@ Converting a SVG to HPGL For vintage plotters, the :ref:`cmd_write` command is capable of generating HPGL code instead of SVG. HPGL output format is automatically selected if the output path file extension is ``.hpgl``. Since HPGL coordinate systems vary widely from -plotter to plotter and even for different physical paper format, the plotter model and the paper format must be provided +plotter to plotter and even for different physical paper format, the plotter model must be provided to the :ref:`cmd_write` command:: - $ vpype read input.svg write --device hp7475a --page-size a4 --landscape --center output.hpgl + $ vpype read input.svg write --device hp7475a output.hpgl +The plotter paper size will be inferred from the current page size (which is set by the input SVG in this case). The plotter type/paper format combination must exist in the built-in or user-provided configuration file. See -:ref:`faq_custom_hpgl_config` for information on how to create one. +:ref:`faq_custom_hpgl_config` for information on how to create one. If a matching plotter paper size cannot be found, +an error will be generated. In this case, the paper size must manually specified with the :option:`--page-size ` option:: + + $ vpype read input.svg write --device hp7475a --page-size a4 --landscape output.hpgl + +Here the :option:`--landscape ` is also used to indicate that landscape orientation is desired. As +for SVG output, the :option:`--center ` is often use to center the geometries in the middle of the page. It is typically useful to optimize the input SVG during the conversion. The following example is typical of real-world use:: @@ -137,9 +144,9 @@ use:: Defining a default HPGL plotter device ====================================== -If you are using the same type of plotter regularly, it may be cumbersome to systematically add the ``--device`` option -to the :ref:`cmd_write` command. The default device can be set in the ``~/.vpype.toml`` configuration file by adding the -following section: +If you are using the same type of plotter regularly, it may be cumbersome to systematically add the :option:`--device +` option to the :ref:`cmd_write` command. The default device can be set in the ``~/.vpype.toml`` +configuration file by adding the following section: .. code-block:: toml @@ -154,7 +161,8 @@ Creating a custom configuration file for a HPGL plotter The configuration for a number of HPGL plotter is bundled with vpype (run ``vpype write --help`` for a list). If your plotter is not included, it is possible to define your own plotter configuration either in `~/.vpype.toml` or any other -file. In the latter case, you must instruct vpype to load the configuration using the ``--config`` option:: +file. In the latter case, you must instruct vpype to load the configuration using the :option:`--config ` global option:: $ vpype --config my_config_file.toml read input.svg [...] write --device my_plotter --page-size a4 output.hpgl diff --git a/tests/test_commands.py b/tests/test_commands.py index 229429b2..04f7993d 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -341,6 +341,19 @@ def test_snap(): assert np.all(lc[0] == np.array([0, 1 + 1j, 2j])) +def test_filter(): + assert len(execute_single_line("filter --min-length 10", [0, 15])) == 1 + assert len(execute_single_line("filter --min-length 10", [0, 10])) == 1 + assert len(execute_single_line("filter --min-length 10", [0, 5])) == 0 + assert len(execute_single_line("filter --max-length 10", [0, 15])) == 0 + assert len(execute_single_line("filter --max-length 10", [0, 10])) == 1 + assert len(execute_single_line("filter --max-length 10", [0, 5])) == 1 + assert len(execute_single_line("filter --closed", [0, 5, 5j, 0])) == 1 + assert len(execute_single_line("filter --closed", [0, 5, 5j])) == 0 + assert len(execute_single_line("filter --not-closed", [0, 5, 5j, 0])) == 0 + assert len(execute_single_line("filter --not-closed", [0, 5, 5j])) == 1 + + @pytest.mark.parametrize("pitch", [0.1, 1, 5, 10, 20, 50, 100, 200, 500]) def test_snap_no_duplicate(pitch: float): """Snap should return no duplicated points and reject lines that degenerate into a single diff --git a/tests/test_hpgl.py b/tests/test_hpgl.py index 62d68975..b5b002d1 100644 --- a/tests/test_hpgl.py +++ b/tests/test_hpgl.py @@ -3,6 +3,31 @@ import vpype as vp from vpype_cli import cli +HPGL_TEST_CASES = [ + ("line 2 3 6 4", "-d simple -p simple", "IN;DF;SP1;PU2,3;PD6,4;PU;SP0;IN;"), + ("line 2 3 50 3", "-d simple -p simple", "IN;DF;SP1;PU2,3;PD10,3;PU;SP0;IN;"), + ("line 2 3 6 4", "-d simple -p aka_simple", "IN;DF;SP1;PU2,3;PD6,4;PU;SP0;IN;"), + ("line 2 3 6 4", "-d simple -p simple_ps10", "IN;DF;PS10;SP1;PU2,3;PD6,4;PU;SP0;IN;"), + ( + "line 2 3 6 4 line -l 2 4 5 2 3", + "-d simple -p simple", + "IN;DF;SP1;PU2,3;PD6,4;PU;SP2;PU4,5;PD2,3;PU;SP0;IN;", + ), + ("line 2 3 6 4", "-d simple -p simple_landscape", "IN;DF;SP1;PU3,8;PD4,4;PU;SP0;IN;"), + ("line 2 3 6 4", "-d simple -p simple_y_up", "IN;DF;SP1;PU2,12;PD6,11;PU;SP0;IN;"), + ( + "line 2 3 6 4", + "-d simple -p simple_rotate_180", + "IN;DF;SP1;PU8,12;PD4,11;PU;SP0;IN;", + ), + ( + "line 2 3 6 4", + "-d simple -p simple_final_pu", + "IN;DF;SP1;PU2,3;PD6,4;PU0,0;SP0;IN;", + ), + ("line 3 5 4 6", "-d double -p simple", "IN;DF;SP1;PU6,10;PD8,12;PU;SP0;IN;"), +] + @pytest.fixture def simple_printer_config(config_file_factory): @@ -105,33 +130,7 @@ def simple_printer_config(config_file_factory): ) -@pytest.mark.parametrize( - ["commands", "write_opts", "expected"], - [ - ("line 2 3 6 4", "-d simple -p simple", "IN;DF;SP1;PU2,3;PD6,4;PU;SP0;IN;"), - ("line 2 3 50 3", "-d simple -p simple", "IN;DF;SP1;PU2,3;PD10,3;PU;SP0;IN;"), - ("line 2 3 6 4", "-d simple -p aka_simple", "IN;DF;SP1;PU2,3;PD6,4;PU;SP0;IN;"), - ("line 2 3 6 4", "-d simple -p simple_ps10", "IN;DF;PS10;SP1;PU2,3;PD6,4;PU;SP0;IN;"), - ( - "line 2 3 6 4 line -l 2 4 5 2 3", - "-d simple -p simple", - "IN;DF;SP1;PU2,3;PD6,4;PU;SP2;PU4,5;PD2,3;PU;SP0;IN;", - ), - ("line 2 3 6 4", "-d simple -p simple_landscape", "IN;DF;SP1;PU3,8;PD4,4;PU;SP0;IN;"), - ("line 2 3 6 4", "-d simple -p simple_y_up", "IN;DF;SP1;PU2,12;PD6,11;PU;SP0;IN;"), - ( - "line 2 3 6 4", - "-d simple -p simple_rotate_180", - "IN;DF;SP1;PU8,12;PD4,11;PU;SP0;IN;", - ), - ( - "line 2 3 6 4", - "-d simple -p simple_final_pu", - "IN;DF;SP1;PU2,3;PD6,4;PU0,0;SP0;IN;", - ), - ("line 3 5 4 6", "-d double -p simple", "IN;DF;SP1;PU6,10;PD8,12;PU;SP0;IN;"), - ], -) +@pytest.mark.parametrize(["commands", "write_opts", "expected"], HPGL_TEST_CASES) def test_hpgl_simple(runner, simple_printer_config, commands, write_opts, expected): # Note: passing a single string to invoke runs it through shlex and removes the windows # path back-slash, thus the split. @@ -139,10 +138,30 @@ def test_hpgl_simple(runner, simple_printer_config, commands, write_opts, expect cli, f"-c {simple_printer_config} {commands} write -f hpgl {write_opts} -".split(" ") ) + assert res.exit_code == 0 assert res.stdout.strip() == expected assert res.stderr.strip() == "" +@pytest.mark.parametrize(["commands", "write_opts", "expected"], HPGL_TEST_CASES) +def test_hpgl_file_written( + runner, simple_printer_config, tmp_path, commands, write_opts, expected +): + file_path = str(tmp_path / "output.hpgl") + + res = runner.invoke( + cli, + f"-c {simple_printer_config} {commands} write -f hpgl {write_opts} {file_path}".split( + " " + ), + ) + + assert res.exit_code == 0 + + with open(file_path) as fp: + assert fp.read().strip() == expected + + def test_hpgl_info(runner, simple_printer_config): res = runner.invoke( cli, @@ -170,9 +189,36 @@ def test_hpgl_info_quiet(runner, simple_printer_config): [(15, 10), "simple"], [(20, 30), "simple_big"], [(30, 20), "simple_big"], + [None, None], ], ) -def test_hpgl_paper_config_from_format(simple_printer_config, paper_format, expected_name): +def test_hpgl_paper_config_from_size(simple_printer_config, paper_format, expected_name): vp.CONFIG_MANAGER.load_config_file(simple_printer_config) pc = vp.CONFIG_MANAGER.get_plotter_config("simple").paper_config_from_size(paper_format) - assert pc.name == expected_name + if expected_name is None: + assert pc is None + else: + assert pc.name == expected_name + + +def test_hpgl_paper_config(simple_printer_config): + vp.CONFIG_MANAGER.load_config_file(simple_printer_config) + assert vp.CONFIG_MANAGER.get_plotter_config("simple").paper_config("simple") is not None + assert vp.CONFIG_MANAGER.get_plotter_config("simple").paper_config("DOESNTEXIST") is None + + +def test_hpgl_paper_size_inference(runner): + res = runner.invoke(cli, "rect 5cm 5cm 5cm 5cm pagesize a4 write -f hpgl -d hp7475a -") + + assert res.exit_code == 0 + assert res.stdout.strip() == ( + "IN;DF;PS4;SP1;PU1608,1849;PD3617,1849,3617,3859,1608,3859,1608,1849;PU11040,7721;" + "SP0;IN;" + ) + + +def test_hpgl_paper_size_inference_fail(runner): + res = runner.invoke(cli, "rect 5cm 5cm 5cm 5cm pagesize a6 write -f hpgl -d hp7475a -") + + assert res.exit_code == 0 # this should probably be non-zero, see #131 + assert res.stdout.strip() == "" diff --git a/tests/utils.py b/tests/utils.py index 06c38f2a..09a82078 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -20,7 +20,7 @@ def line_collection_contains(lc: vp.LineCollection, line: Sequence[complex]) -> return False -def execute_single_line(pipeline: str, line: np.ndarray) -> vp.LineCollection: +def execute_single_line(pipeline: str, line: vp.LineLike) -> vp.LineCollection: """Execute a pipeline on a single line. The pipeline is expected to remain single layer. Returns: diff --git a/vpype/config.py b/vpype/config.py index 4db182c6..25ee8566 100644 --- a/vpype/config.py +++ b/vpype/config.py @@ -112,17 +112,23 @@ def paper_config(self, paper: str) -> Optional[PaperConfig]: return pc return None - def paper_config_from_size(self, page_size: Tuple[float, float]) -> Optional[PaperConfig]: + def paper_config_from_size( + self, page_size: Optional[Tuple[float, float]] + ) -> Optional[PaperConfig]: """Look for a paper configuration matching ``paper_format`` and return it if found. Args: - page_size: desired page size + page_size: tuple of desired page size (may be ``None``, in which case ``None`` is + returned Returns: the :class:`PaperConfig` instance corresponding to ``paper_format`` or None if not found """ + if page_size is None: + return None + def _isclose_tuple(a, b): return all(math.isclose(aa, bb) for aa, bb in zip(a, b)) @@ -187,11 +193,11 @@ def get_plotter_list(self) -> List[str]: """ return list(self.config.get("device", {}).keys()) - def get_plotter_config(self, name: str) -> Optional[PlotterConfig]: + def get_plotter_config(self, name: Optional[str]) -> Optional[PlotterConfig]: """Returns a :class:`PlotterConfig` instance for plotter ``name``. Args: - name: name of desired plotter + name: name of desired plotter (may be ``None``, in which case ``None`` is returned) Returns: :class:`PlotterConfig` instance or None if not found diff --git a/vpype/io.py b/vpype/io.py index 718aa62e..a9c7429d 100644 --- a/vpype/io.py +++ b/vpype/io.py @@ -604,3 +604,4 @@ def complex_to_str(p: complex) -> str: ) output.write("SP0;IN;\n") + output.flush() diff --git a/vpype_cli/write.py b/vpype_cli/write.py index 9ddc2c89..3cf93c88 100644 --- a/vpype_cli/write.py +++ b/vpype_cli/write.py @@ -1,18 +1,10 @@ import logging import os -from typing import Optional, Tuple +from typing import Optional import click -from vpype import ( - CONFIG_MANAGER, - PAGE_SIZES, - Document, - convert_page_size, - global_processor, - write_hpgl, - write_svg, -) +import vpype as vp from .cli import cli @@ -30,7 +22,7 @@ command. The page size can be overridden using the `--page-size SIZE` option. `SIZE` may be one of: - {', '.join(PAGE_SIZES.keys())} + {', '.join(vp.PAGE_SIZES.keys())} Alternatively, a custom size can be specified in the form of `WIDTHxHEIGHT`. `WIDTH` and `HEIGHT` may include units. If only one has an unit, the other is assumed to have the @@ -67,13 +59,17 @@ corresponding device must be configured in the built-in or a user-provided configuration file (see the documentation for more details). The following devices are currently available: - {', '.join(CONFIG_MANAGER.get_plotter_list())} + {', '.join(vp.CONFIG_MANAGER.get_plotter_list())} + +In HPGL mode, this command will try to infer the paper size to use based on the current page +size (the current page size is set by the `read` command based on the input file and can be +manually set or changed with the `pagesize` command). An error will be displayed if no +corresponding paper size if found. Use the `--page-size` option with a format defined in the +device's configuration to manually specify with paper size to use. -In HPGL mode, because the coordinate system depends on the configuration, the `--page-size` -option is mandatory, and is restricted to the paper formats defined in the corresponding -device's configuration. The plotter may as well need to be specifically configured for the -desired paper size (e.g. for A4 or A3, the HP 7475a's corresponding DIP switch must be set to -metric mode). +The plotter may need to be specifically configured for the desired paper size (e.g. for A4 or +A3, the HP 7475a's corresponding DIP switch must be set to metric mode). A note will be +displayed as a reminder and can be hidden using the `--quiet` option. The `--landscape` and `--center` options are accepted and honored in HPGL. @@ -166,9 +162,9 @@ help="[HPGL only] Do not display the plotter configuration or paper loading information.", ) @click.pass_obj # to obtain the command string -@global_processor +@vp.global_processor def write( - document: Document, + document: vp.Document, cmd_string: Optional[str], output, file_format: str, @@ -196,11 +192,11 @@ def write( if file_format == "svg": page_size_px = None if page_size: - page_size_px = convert_page_size(page_size) + page_size_px = vp.convert_page_size(page_size) if landscape: page_size_px = page_size_px[::-1] - write_svg( + vp.write_svg( output=output, document=document, page_size=page_size_px, @@ -211,26 +207,37 @@ def write( color_mode=color_mode, ) elif file_format == "hpgl": - if page_size: - write_hpgl( - output=output, - document=document, - landscape=landscape, - center=center, - device=device, - page_size=page_size, - velocity=velocity, - quiet=quiet, - ) - else: - logging.error( - "write: page size is mandatory for HPGL, please use the `--page-size SIZE` " - "option" - ) + if not page_size: + config = vp.CONFIG_MANAGER.get_plotter_config(device) + if config is not None: + paper_config = config.paper_config_from_size(document.page_size) + else: + paper_config = None + + if paper_config and document.page_size is not None: + page_size = paper_config.name + landscape = document.page_size[0] > document.page_size[1] + else: + logging.error( + "write: the plotter page size could not be inferred from the current page " + "size (use the `--page-size SIZE` option)" + ) + return document + + vp.write_hpgl( + output=output, + document=document, + landscape=landscape, + center=center, + device=device, + page_size=page_size, + velocity=velocity, + quiet=quiet, + ) else: logging.warning( f"write: format could not be inferred or format unknown '{file_format}', " - "no file created" + "no file created (use the `--format` option)" ) return document