Repository: abey79/vpype

Issue:
Fix default value for %/missing SVG height/width
Relevant conversation: https://ptb.discord.com/channels/499297341472505858/748589023731122277/959115720208310332

In a nutshell: in cases where <svg> widht/height is missing (or in percent units), `viewBox` should be used. By providing values for `width`/`height` to `svgelement.SVG.parse()`, vpype precludes svgelement to perform this.


Discussion:

Resolving patch:
diff --git a/vpype/io.py b/vpype/io.py
--- a/vpype/io.py
+++ b/vpype/io.py
@@ -44,10 +44,6 @@
 ]
 
 
-_DEFAULT_WIDTH = 1000
-_DEFAULT_HEIGHT = 1000
-
-
 class _ComplexStack:
     """Complex number stack implemented with a numpy array"""
 
@@ -383,8 +379,8 @@ def read_svg(
     crop: bool = True,
     simplify: bool = False,
     parallel: bool = False,
-    default_width: float = _DEFAULT_WIDTH,
-    default_height: float = _DEFAULT_HEIGHT,
+    default_width: float | None = None,
+    default_height: float | None = None,
 ) -> tuple[LineCollection, float, float]:
     """Read a SVG file an return its content as a :class:`LineCollection` instance.
 
@@ -392,6 +388,12 @@ def read_svg(
     Optionally, the geometries are simplified using Shapely, using the value of *quantization*
     as tolerance.
 
+    The page size is set based on the ``width`` and ``height`` attributes of the ``<svg>`` tag.
+    If these attributes are missing or expressed in percent, ``svgelements`` attempts to use
+    the ``viewBox`` attribute instead, or reverts to a 1000x1000px page size. This behaviour
+    can be overridden by providing values for the ``default_width`` and ``default_height``
+    arguments.
+
     Args:
         file: path of the SVG file or stream object
         quantization: maximum size of segment used to approximate curved geometries
@@ -429,8 +431,8 @@ def read_multilayer_svg(
     crop: bool = True,
     simplify: bool = False,
     parallel: bool = False,
-    default_width: float = _DEFAULT_WIDTH,
-    default_height: float = _DEFAULT_HEIGHT,
+    default_width: float | None = None,
+    default_height: float | None = None,
 ) -> Document:
     """Read a multilayer SVG file and return its content as a :class:`Document` instance
     retaining the SVG's layer structure and its dimension.
@@ -447,6 +449,12 @@ def read_multilayer_svg(
     Optionally, the geometries are simplified using Shapely, using the value of *quantization*
     as tolerance.
 
+    The page size is set based on the ``width`` and ``height`` attributes of the ``<svg>`` tag.
+    If these attributes are missing or expressed in percent, ``svgelements`` attempts to use
+    the ``viewBox`` attribute instead, or reverts to a 1000x1000px page size. This behaviour
+    can be overridden by providing values for the ``default_width`` and ``default_height``
+    arguments.
+
     Args:
         file: path of the SVG file or stream object
         quantization: maximum size of segment used to approximate curved geometries
@@ -534,8 +542,8 @@ def read_svg_by_attributes(
     crop: bool = True,
     simplify: bool = False,
     parallel: bool = False,
-    default_width: float = _DEFAULT_WIDTH,
-    default_height: float = _DEFAULT_HEIGHT,
+    default_width: float | None = None,
+    default_height: float | None = None,
 ) -> Document:
     """Read a SVG file by sorting geometries by unique combination of provided attributes.
 
@@ -543,6 +551,12 @@ def read_svg_by_attributes(
     Optionally, the geometries are simplified using Shapely, using the value of *quantization*
     as tolerance.
 
+    The page size is set based on the ``width`` and ``height`` attributes of the ``<svg>`` tag.
+    If these attributes are missing or expressed in percent, ``svgelements`` attempts to use
+    the ``viewBox`` attribute instead, or reverts to a 1000x1000px page size. This behaviour
+    can be overridden by providing values for the ``default_width`` and ``default_height``
+    arguments.
+
     Args:
         file: path of the SVG file or stream object
         attributes: attributes by which the object should be sorted
diff --git a/vpype_cli/read.py b/vpype_cli/read.py
--- a/vpype_cli/read.py
+++ b/vpype_cli/read.py
@@ -64,7 +64,6 @@
     "-ds",
     "--display-size",
     type=PageSizeType(),
-    default="a4",
     help=(
         "Display size to use for SVG with width/height expressed as percentage or missing "
         "altogether (see `write` command for possible format)."
@@ -89,7 +88,7 @@ def read(
     simplify: bool,
     parallel: bool,
     no_crop: bool,
-    display_size: tuple[float, float],
+    display_size: tuple[float, float] | None,
     display_landscape: bool,
 ) -> vp.Document:
     """Extract geometries from a SVG file.
@@ -144,9 +143,9 @@ def read(
     In general, SVG boundaries are determined by the `width` and `height` of the top-level
     <svg> tag. However, the some SVG may have their width and/or height specified as percent
     value or even miss them altogether (in which case they are assumed to be set to 100%). In
-    these cases, vpype considers by default that 100% corresponds to a A4 page in portrait
-    orientation. The options `--display-size FORMAT` and `--display-landscape` can be used
-    to specify a different format.
+    these cases, vpype attempts to use the `viewBox` attribute to determine the page size, or
+    revert to a 1000x1000px default. The options `--display-size FORMAT` and
+    `--display-landscape` can be used to specify a different format in such instances.
 
     When importing the SVG, the `read` commands attempts to extract the SVG attributes that
     are common to all paths within a layer. The "stroke", "stroke-width" and "inkscape:label"
@@ -186,9 +185,11 @@ def read(
             vpype read --no-crop input_file.svg [...]
     """
 
-    width, height = display_size
-    if display_landscape:
-        width, height = height, width
+    default_width = default_height = None
+    if display_size is not None:
+        default_width, default_height = display_size
+        if display_landscape and default_width < default_height:
+            default_width, default_height = default_height, default_width
 
     if file == "-":
         file = sys.stdin
@@ -214,8 +215,8 @@ def read(
             crop=not no_crop,
             simplify=simplify,
             parallel=parallel,
-            default_width=width,
-            default_height=height,
+            default_width=default_width,
+            default_height=default_height,
         )
 
         document.add(lc, single_to_layer_id(layer, document), with_metadata=True)
@@ -232,8 +233,8 @@ def read(
                 crop=not no_crop,
                 simplify=simplify,
                 parallel=parallel,
-                default_width=width,
-                default_height=height,
+                default_width=default_width,
+                default_height=default_height,
             )
         else:
             doc = vp.read_svg_by_attributes(
@@ -243,8 +244,8 @@ def read(
                 crop=not no_crop,
                 simplify=simplify,
                 parallel=parallel,
-                default_width=width,
-                default_height=height,
+                default_width=default_width,
+                default_height=default_height,
             )
         document.extend(doc)
 


Test patch:
diff --git a/tests/test_files.py b/tests/test_files.py
--- a/tests/test_files.py
+++ b/tests/test_files.py
@@ -506,3 +506,111 @@ def test_write_dont_set_date(capsys):
     vpype_cli.execute("line 0 0 10 10 write -f svg --dont-set-date -")
     output = capsys.readouterr().out
     assert "<dc:date>" not in output
+
+
+_PAGE_SIZE_EXAMPLES = [
+    (
+        """<?xml version="1.0"?><svg>
+            <line x1="0" y1="0" x2="10" y2="10" fill="red" />
+        </svg>""",
+        (None, None),
+        (1000.0, 1000.0),
+    ),
+    (
+        """<?xml version="1.0"?><svg viewBox="0 0 900 800">
+            <line x1="0" y1="0" x2="10" y2="10" fill="red" />
+        </svg>""",
+        (None, None),
+        (900.0, 800.0),
+    ),
+    (
+        """<?xml version="1.0"?><svg viewBox="0 0 900 800">
+            <line x1="0" y1="0" x2="10" y2="10" fill="red" />
+        </svg>""",
+        (700, 600),
+        (700.0, 600.0),
+    ),
+    (
+        """<?xml version="1.0"?><svg width="500" height="300">
+            <line x1="0" y1="0" x2="10" y2="10" fill="red" />
+        </svg>""",
+        (700, 650),
+        (500, 300),
+    ),
+    (
+        """<?xml version="1.0"?><svg width="500" height="300">
+            <line x1="0" y1="0" x2="10" y2="10" fill="red" />
+        </svg>""",
+        (None, None),
+        (500, 300),
+    ),
+    (
+        """<?xml version="1.0"?><svg width="500" height="300" viewBox="0 0 600 750">
+            <line x1="0" y1="0" x2="10" y2="10" fill="red" />
+        </svg>""",
+        (700, 600),
+        (500, 300),
+    ),
+    (
+        """<?xml version="1.0"?><svg width="500" height="300" viewBox="0 0 600 750">
+            <line x1="0" y1="0" x2="10" y2="10" fill="red" />
+        </svg>""",
+        (None, None),
+        (500, 300),
+    ),
+]
+
+
+@pytest.mark.parametrize(["svg", "default", "target"], _PAGE_SIZE_EXAMPLES)
+def test_read_command_page_size(
+    tmp_path, svg: str, default: tuple[float, float], target: tuple[float, float]
+):
+    path = _write_svg_file(tmp_path, svg)
+    args = ""
+    if default[0] is not None and default[1] is not None:
+        args += f"--display-size {default[0]:.3f}x{default[1]:.3f} "
+        if default[0] > default[1]:
+            args += f"--display-landscape"
+    doc = vpype_cli.execute(f"read {args} {path}")
+
+    assert doc.page_size == pytest.approx(target)
+
+
+@pytest.mark.parametrize(["svg", "default", "target"], _PAGE_SIZE_EXAMPLES)
+def test_read_multilayer_svg_default_page_size(
+    tmp_path, svg: str, default: tuple[float, float], target: tuple[float, float]
+):
+    path = _write_svg_file(tmp_path, svg)
+    doc = vp.read_multilayer_svg(
+        path, quantization=0.1, default_width=default[0], default_height=default[1]
+    )
+
+    assert doc.page_size == pytest.approx(target)
+
+
+@pytest.mark.parametrize(["svg", "default", "target"], _PAGE_SIZE_EXAMPLES)
+def test_read_svg_default_page_size(
+    tmp_path, svg: str, default: tuple[float, float], target: tuple[float, float]
+):
+    path = _write_svg_file(tmp_path, svg)
+    _, width, height = vp.read_svg(
+        path, quantization=0.1, default_width=default[0], default_height=default[1]
+    )
+
+    assert (width, height) == pytest.approx(target)
+
+
+@pytest.mark.parametrize(["svg", "default", "target"], _PAGE_SIZE_EXAMPLES)
+def test_read_svg_by_attributes_default_page_size(
+    tmp_path, svg: str, default: tuple[float, float], target: tuple[float, float]
+):
+    path = _write_svg_file(tmp_path, svg)
+    doc = vp.read_svg_by_attributes(
+        path,
+        quantization=0.1,
+        attributes=["fill"],
+        default_width=default[0],
+        default_height=default[1],
+    )
+
+    assert doc.page_size == pytest.approx(target)