Repository: abey79/vpype

Issue:
Bug: `layout tight` does a 0x0 page instead of a tight layout



Discussion:

Resolving patch:
diff --git a/vpype_cli/operations.py b/vpype_cli/operations.py
--- a/vpype_cli/operations.py
+++ b/vpype_cli/operations.py
@@ -512,6 +512,8 @@ def pagesize(document: vp.Document, size, landscape) -> vp.Document:
 
     {', '.join(vp.PAGE_SIZES.keys())}
 
+Note that `tight` is special case, see below.
+
 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
 same unit. If none have units, both are assumed to be pixels by default. Here are some
@@ -531,13 +533,24 @@ def pagesize(document: vp.Document, size, landscape) -> vp.Document:
 Optionally, this command can scale the geometries to fit specified margins with the
 `--fit-to-margins` option.
 
+When SIZE is `tight`, the page size is set to fit the width and height of the existing
+geometries. If `--fit-to-margins` is used, the page size is increased to accommodate the
+margin. By construction, `--align` and `--valign` have no effect with `tight`.
+
+On an empty pipeline, `layout` simply sets the page size to SIZE, unless `tight` is used. In
+this case, `layout` has no effect at all.   
+
 Examples:
 
 \b
     Fit the geometries to 3cm margins with top alignment (a generally
     pleasing arrangement for square designs on portrait-oriented pages):
 
-        vpype read input.svg layout --fit-to-margins 3cm --valign top a4 write.svg
+        vpype read input.svg layout --fit-to-margins 3cm --valign top a4 write output.svg
+        
+    Set the page size to the geometries' boundary, with a 1cm margin:
+    
+        vpype read input.svg layout --fit-to-margins 3cm tight write output.svg
 """
 
 
@@ -578,17 +591,26 @@ def layout(
     """Layout command"""
 
     size = _normalize_page_size(size, landscape)
-
-    document.page_size = size
     bounds = document.bounds()
+    tight = size == vp.PAGE_SIZES["tight"]
 
+    # handle empty geometry special cases
     if bounds is None:
-        # nothing to layout
+        if not tight:
+            document.page_size = size
         return document
 
     min_x, min_y, max_x, max_y = bounds
     width = max_x - min_x
     height = max_y - min_y
+
+    # handle "tight" special case
+    if tight:
+        extra = 2 * (margin or 0.0)
+        size = width + extra, height + extra
+
+    document.page_size = size
+
     if margin is not None:
         document.translate(-min_x, -min_y)
         scale = min((size[0] - 2 * margin) / width, (size[1] - 2 * margin) / height)


Test patch:
diff --git a/tests/test_commands.py b/tests/test_commands.py
--- a/tests/test_commands.py
+++ b/tests/test_commands.py
@@ -546,23 +546,43 @@ def test_splitall_filter_duplicates(line, expected):
         ("-m 3cm -h right 10x20cm", (3, 8, 7, 12)),
         ("-m 3cm -h right -l 20x10cm", (13, 3, 17, 7)),
         ("-m 3cm -h right -l 10x20cm", (13, 3, 17, 7)),
+        ("tight", (0, 0, 1, 1)),
+        ("-m 1cm tight", (1, 1, 2, 2)),
     ],
 )
-def test_layout(runner, args, expected_bounds):
-    document = vp.Document()
+def test_layout(args, expected_bounds):
+    doc = vpype_cli.execute(f"random -n 100 rect 0 0 1cm 1cm layout {args}")
+    assert doc.bounds() == pytest.approx([i * CM for i in expected_bounds])
 
-    @cli.command()
-    @global_processor
-    def sample(doc: vp.Document):
-        nonlocal document
-        document = doc
 
-    res = runner.invoke(cli, f"random -n 100 rect 0 0 1cm 1cm layout {args} sample")
-    assert res.exit_code == 0
-    bounds = document.bounds()
-    assert bounds is not None
-    for act, exp in zip(bounds, expected_bounds):
-        assert act == pytest.approx(exp * CM)
+def test_layout_tight():
+    """`layout tight` fits tightly the page size around the geometry, accommodating for margins
+    if provided"""
+
+    doc = vpype_cli.execute("rect 5cm 10cm 2cm 3cm layout tight")
+    assert doc.bounds() == pytest.approx((0, 0, 2 * CM, 3 * CM))
+    assert doc.page_size == pytest.approx((2 * CM, 3 * CM))
+
+    doc = vpype_cli.execute("rect 5cm 10cm 2cm 3cm layout -m 1cm tight")
+    assert doc.bounds() == pytest.approx((CM, CM, 3 * CM, 4 * CM))
+    assert doc.page_size == pytest.approx((4 * CM, 5 * CM))
+
+
+def test_layout_empty():
+    """page size is set to size provided to layout, unless it's tight, in which case it is
+    unchanged"""
+
+    doc = vpype_cli.execute("layout 10x12cm")
+    assert doc.page_size == pytest.approx((10 * CM, 12 * CM))
+
+    doc = vpype_cli.execute("pagesize a3 layout 10x12cm")
+    assert doc.page_size == pytest.approx((10 * CM, 12 * CM))
+
+    doc = vpype_cli.execute("layout tight")
+    assert doc.page_size is None
+
+    doc = vpype_cli.execute("pagesize 10x12cm layout tight")
+    assert doc.page_size == pytest.approx((10 * CM, 12 * CM))
 
 
 @pytest.mark.parametrize("font_name", vp.FONT_NAMES)