From b60b317c4021b5912c91d835fd68fb5f233c5ee4 Mon Sep 17 00:00:00 2001 From: Martin Mahner Date: Sat, 26 Jul 2025 08:38:49 +0200 Subject: [PATCH 1/2] Remove `width` and `height` attributes from SVGs, use `viewBox` instead. Update SVG dimensions to use pixel units. Add regression test to validate changes. --- CHANGES.rst | 12 +++++ qrcode/image/svg.py | 12 ++--- qrcode/tests/regression/__init__.py | 0 qrcode/tests/regression/test_svg_dimension.py | 51 +++++++++++++++++++ 4 files changed, 69 insertions(+), 6 deletions(-) create mode 100644 qrcode/tests/regression/__init__.py create mode 100644 qrcode/tests/regression/test_svg_dimension.py diff --git a/CHANGES.rst b/CHANGES.rst index 262b84af..2397b311 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -29,9 +29,21 @@ Removed in v9.0: StyledPilImage(embeded_image=..., embeded_image_path=...) # Old StyledPilImage(embedded_image=..., embedded_image_path=...) # New +- The ``width`` and ``height`` attributes will be removed from the ````tag. + Instead, the``viewBox`` attribute is now used for defining the dimensions. + Additionally, all SVG elements now utilize pixel units rather than millimeters, + which may cause rendering differences in browsers. (`#351`_) + +.. _#351: https://github.com/lincolnloop/python-qrcode/issues/351 + Change Log ========== +WIP (9.0) +--------- + +- **Removed** ``width=.. height=...`` attributes from SVG tag, using viewBox instead. SVG elements now use pixel units instead of millimeters. + WIP --- diff --git a/qrcode/image/svg.py b/qrcode/image/svg.py index 356f4f43..39ec1e2f 100644 --- a/qrcode/image/svg.py +++ b/qrcode/image/svg.py @@ -44,9 +44,9 @@ def units(self, pixels: int | Decimal, text: Literal[True] = True) -> str: ... def units(self, pixels, text=True): """ - A box_size of 10 (default) equals 1mm. + Returns pixel values directly. """ - units = Decimal(pixels) / 10 + units = Decimal(pixels) if not text: return units units = units.quantize(Decimal("0.001")) @@ -56,7 +56,7 @@ def units(self, pixels, text=True): units = units.quantize(d, context=context) except decimal.Inexact: pass - return f"{units}mm" + return f"{units}" def save(self, stream, kind=None): self.check_kind(kind=kind) @@ -71,11 +71,11 @@ def new_image(self, **kwargs): def _svg(self, tag=None, version="1.1", **kwargs): if tag is None: tag = ET.QName(self._SVG_namespace, "svg") - dimension = self.units(self.pixel_size) + dimension = self.units(self.pixel_size, text=False) + viewBox = kwargs.get("viewBox", f"0 0 {dimension} {dimension}") + kwargs["viewBox"] = viewBox return ET.Element( tag, - width=dimension, - height=dimension, version=version, **kwargs, ) diff --git a/qrcode/tests/regression/__init__.py b/qrcode/tests/regression/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/qrcode/tests/regression/test_svg_dimension.py b/qrcode/tests/regression/test_svg_dimension.py new file mode 100644 index 00000000..714058aa --- /dev/null +++ b/qrcode/tests/regression/test_svg_dimension.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +import io +import re +from typing import TYPE_CHECKING + +import pytest + +import qrcode +from qrcode.image import svg +from qrcode.tests.consts import UNICODE_TEXT + +if TYPE_CHECKING: + from qrcode.image.base import BaseImageWithDrawer + + +@pytest.mark.parametrize( + "image_factory", + [ + svg.SvgFragmentImage, + svg.SvgImage, + svg.SvgFillImage, + svg.SvgPathImage, + svg.SvgPathFillImage, + ], +) +def test_svg_no_width_height(image_factory: BaseImageWithDrawer) -> None: + """Test that SVG output doesn't have width and height attributes.""" + qr = qrcode.QRCode() + qr.add_data(UNICODE_TEXT) + + # Create a svg with the specified factory and (optional) module drawer + img = qr.make_image(image_factory=image_factory) + svg_str = img.to_string().decode("utf-8") + + # Check that width and height attributes are not present in the SVG tag + svg_tag_match = re.search(r"]*>", svg_str) + assert svg_tag_match, "SVG tag not found" + + svg_tag = svg_tag_match.group(0) + assert "width=" not in svg_tag, "width attribute should not be present" + assert "height=" not in svg_tag, "height attribute should not be present" + + # Check that viewBox is present and uses pixels (no mm suffix) + viewbox_match = re.search(r'viewBox="([^"]*)"', svg_tag) + assert viewbox_match, "viewBox attribute not found" + viewbox = viewbox_match.group(1) + assert "mm" not in viewbox, "viewBox should use pixels, not mm" + + # Check that inner elements use pixels (no mm suffix) + assert "mm" not in svg_str, "SVG elements should use pixels, not mm" From 25dadabe9eacd833431918ee9ef206f8029defae Mon Sep 17 00:00:00 2001 From: Martin Mahner Date: Sat, 26 Jul 2025 10:05:17 +0200 Subject: [PATCH 2/2] Improve types and comments --- qrcode/image/svg.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/qrcode/image/svg.py b/qrcode/image/svg.py index 39ec1e2f..6b0ea1a8 100644 --- a/qrcode/image/svg.py +++ b/qrcode/image/svg.py @@ -42,13 +42,20 @@ def units(self, pixels: int | Decimal, text: Literal[False]) -> Decimal: ... @overload def units(self, pixels: int | Decimal, text: Literal[True] = True) -> str: ... - def units(self, pixels, text=True): + def units(self, pixels: int, text=True) -> Decimal | str: """ - Returns pixel values directly. + Converts pixel values into a decimal representation with up to three decimal + places of precision or a string representation, optionally rounding to + lower precision without data loss. """ units = Decimal(pixels) if not text: return units + + # Round the decimal to 3 decimal places first, then try to reduce precision + # further by attempting to round to 2 decimals, 1 decimal, and whole numbers. + # If any rounding causes data loss (raises Inexact), keep the previous + # precision. units = units.quantize(Decimal("0.001")) context = decimal.Context(traps=[decimal.Inexact]) try: @@ -56,7 +63,8 @@ def units(self, pixels, text=True): units = units.quantize(d, context=context) except decimal.Inexact: pass - return f"{units}" + + return str(units) def save(self, stream, kind=None): self.check_kind(kind=kind)