From 3d8df956a072ba9e04c3cea45f284e617a66acf2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Plo=CC=88tz?= Date: Wed, 12 Mar 2025 16:21:47 +0100 Subject: [PATCH 1/2] feat: add SvgRoundedModuleDrawer class for SVG QR codes with rounded corners --- qrcode/image/styles/moduledrawers/svg.py | 135 +++++++++++++++++++++++ qrcode/tests/test_qrcode_svg.py | 28 +++++ 2 files changed, 163 insertions(+) diff --git a/qrcode/image/styles/moduledrawers/svg.py b/qrcode/image/styles/moduledrawers/svg.py index cf5b9e7d..ea16174f 100644 --- a/qrcode/image/styles/moduledrawers/svg.py +++ b/qrcode/image/styles/moduledrawers/svg.py @@ -137,3 +137,138 @@ def subpath(self, box) -> str: # x,y is the point the arc is drawn to return f"M{x0},{yh}A{h},{h} 0 0 0 {x1},{yh}A{h},{h} 0 0 0 {x0},{yh}z" + + +class SvgRoundedModuleDrawer(SvgPathQRModuleDrawer): + """ + Draws the modules with all 90 degree corners replaced with rounded edges. + + radius_ratio determines the radius of the rounded edges - a value of 1 + means that an isolated module will be drawn as a circle, while a value of 0 + means that the radius of the rounded edge will be 0 (and thus back to 90 + degrees again). + """ + needs_neighbors = True + + def __init__(self, radius_ratio: Decimal = Decimal(1), **kwargs): + super().__init__(**kwargs) + self.radius_ratio = radius_ratio + + def initialize(self, *args, **kwargs) -> None: + super().initialize(*args, **kwargs) + self.corner_radius = self.box_half * self.radius_ratio + + def drawrect(self, box, is_active): + if not is_active: + return + + # Check if is_active has neighbor information (ActiveWithNeighbors object) + if hasattr(is_active, 'N'): + # Neighbor information is available + self.img._subpaths.append(self.subpath(box, is_active)) + else: + # No neighbor information available, draw a square with all corners rounded + self.img._subpaths.append(self.subpath_all_rounded(box)) + + def subpath_all_rounded(self, box) -> str: + """Draw a module with all corners rounded.""" + coords = self.coords(box) + x0 = self.img.units(coords.x0, text=False) + y0 = self.img.units(coords.y0, text=False) + x1 = self.img.units(coords.x1, text=False) + y1 = self.img.units(coords.y1, text=False) + r = self.img.units(self.corner_radius, text=False) + + # Build the path with all corners rounded + path = [] + + # Start at top-left after the rounded part + path.append(f"M{x0 + r},{y0}") + + # Top edge to top-right corner + path.append(f"H{x1 - r}") + # Top-right rounded corner + path.append(f"A{r},{r} 0 0 1 {x1},{y0 + r}") + + # Right edge to bottom-right corner + path.append(f"V{y1 - r}") + # Bottom-right rounded corner + path.append(f"A{r},{r} 0 0 1 {x1 - r},{y1}") + + # Bottom edge to bottom-left corner + path.append(f"H{x0 + r}") + # Bottom-left rounded corner + path.append(f"A{r},{r} 0 0 1 {x0},{y1 - r}") + + # Left edge to top-left corner + path.append(f"V{y0 + r}") + # Top-left rounded corner + path.append(f"A{r},{r} 0 0 1 {x0 + r},{y0}") + + # Close the path + path.append("z") + + return "".join(path) + + def subpath(self, box, is_active) -> str: + """Draw a module with corners rounded based on neighbor information.""" + # Determine which corners should be rounded + nw_rounded = not is_active.W and not is_active.N + ne_rounded = not is_active.N and not is_active.E + se_rounded = not is_active.E and not is_active.S + sw_rounded = not is_active.S and not is_active.W + + coords = self.coords(box) + x0 = self.img.units(coords.x0, text=False) + y0 = self.img.units(coords.y0, text=False) + x1 = self.img.units(coords.x1, text=False) + y1 = self.img.units(coords.y1, text=False) + r = self.img.units(self.corner_radius, text=False) + + # Build the path + path = [] + + # Start at top-left and move clockwise + if nw_rounded: + # Start at top-left corner, after the rounded part + path.append(f"M{x0 + r},{y0}") + else: + # Start at the top-left corner + path.append(f"M{x0},{y0}") + + # Top edge to top-right corner + if ne_rounded: + path.append(f"H{x1 - r}") + # Top-right rounded corner + path.append(f"A{r},{r} 0 0 1 {x1},{y0 + r}") + else: + path.append(f"H{x1}") + + # Right edge to bottom-right corner + if se_rounded: + path.append(f"V{y1 - r}") + # Bottom-right rounded corner + path.append(f"A{r},{r} 0 0 1 {x1 - r},{y1}") + else: + path.append(f"V{y1}") + + # Bottom edge to bottom-left corner + if sw_rounded: + path.append(f"H{x0 + r}") + # Bottom-left rounded corner + path.append(f"A{r},{r} 0 0 1 {x0},{y1 - r}") + else: + path.append(f"H{x0}") + + # Left edge back to start + if nw_rounded: + path.append(f"V{y0 + r}") + # Top-left rounded corner + path.append(f"A{r},{r} 0 0 1 {x0 + r},{y0}") + else: + path.append(f"V{y0}") + + # Close the path + path.append("z") + + return "".join(path) diff --git a/qrcode/tests/test_qrcode_svg.py b/qrcode/tests/test_qrcode_svg.py index 4774b245..20075593 100644 --- a/qrcode/tests/test_qrcode_svg.py +++ b/qrcode/tests/test_qrcode_svg.py @@ -2,6 +2,8 @@ import qrcode from qrcode.image import svg +from qrcode.image.styles.moduledrawers.svg import SvgRoundedModuleDrawer +from decimal import Decimal from qrcode.tests.consts import UNICODE_TEXT @@ -52,3 +54,29 @@ def test_svg_circle_drawer(): qr.add_data(UNICODE_TEXT) img = qr.make_image(image_factory=svg.SvgPathImage, module_drawer="circle") img.save(io.BytesIO()) + + +def test_svg_rounded_module_drawer(): + """Test that the SvgRoundedModuleDrawer works correctly.""" + qr = qrcode.QRCode() + qr.add_data(UNICODE_TEXT) + + # Test with default parameters + module_drawer = SvgRoundedModuleDrawer() + img = qr.make_image(image_factory=svg.SvgPathImage, module_drawer=module_drawer) + img.save(io.BytesIO()) + + # Test with custom radius_ratio + module_drawer = SvgRoundedModuleDrawer(radius_ratio=Decimal('0.5')) + img = qr.make_image(image_factory=svg.SvgPathImage, module_drawer=module_drawer) + img.save(io.BytesIO()) + + # Test with custom size_ratio + module_drawer = SvgRoundedModuleDrawer(size_ratio=Decimal('0.8')) + img = qr.make_image(image_factory=svg.SvgPathImage, module_drawer=module_drawer) + img.save(io.BytesIO()) + + # Test with both custom parameters + module_drawer = SvgRoundedModuleDrawer(radius_ratio=Decimal('0.3'), size_ratio=Decimal('0.9')) + img = qr.make_image(image_factory=svg.SvgPathImage, module_drawer=module_drawer) + img.save(io.BytesIO()) From 9c083ee683127a39e7038acb3a2f362376927269 Mon Sep 17 00:00:00 2001 From: Martin Mahner Date: Wed, 23 Jul 2025 13:02:27 +0200 Subject: [PATCH 2/2] Ruff autoformat --- qrcode/image/styles/moduledrawers/svg.py | 39 ++++++++++++------------ qrcode/tests/test_qrcode_svg.py | 16 +++++----- 2 files changed, 29 insertions(+), 26 deletions(-) diff --git a/qrcode/image/styles/moduledrawers/svg.py b/qrcode/image/styles/moduledrawers/svg.py index ea16174f..110da058 100644 --- a/qrcode/image/styles/moduledrawers/svg.py +++ b/qrcode/image/styles/moduledrawers/svg.py @@ -148,6 +148,7 @@ class SvgRoundedModuleDrawer(SvgPathQRModuleDrawer): means that the radius of the rounded edge will be 0 (and thus back to 90 degrees again). """ + needs_neighbors = True def __init__(self, radius_ratio: Decimal = Decimal(1), **kwargs): @@ -161,9 +162,9 @@ def initialize(self, *args, **kwargs) -> None: def drawrect(self, box, is_active): if not is_active: return - + # Check if is_active has neighbor information (ActiveWithNeighbors object) - if hasattr(is_active, 'N'): + if hasattr(is_active, "N"): # Neighbor information is available self.img._subpaths.append(self.subpath(box, is_active)) else: @@ -178,36 +179,36 @@ def subpath_all_rounded(self, box) -> str: x1 = self.img.units(coords.x1, text=False) y1 = self.img.units(coords.y1, text=False) r = self.img.units(self.corner_radius, text=False) - + # Build the path with all corners rounded path = [] - + # Start at top-left after the rounded part path.append(f"M{x0 + r},{y0}") - + # Top edge to top-right corner path.append(f"H{x1 - r}") # Top-right rounded corner path.append(f"A{r},{r} 0 0 1 {x1},{y0 + r}") - + # Right edge to bottom-right corner path.append(f"V{y1 - r}") # Bottom-right rounded corner path.append(f"A{r},{r} 0 0 1 {x1 - r},{y1}") - + # Bottom edge to bottom-left corner path.append(f"H{x0 + r}") # Bottom-left rounded corner path.append(f"A{r},{r} 0 0 1 {x0},{y1 - r}") - + # Left edge to top-left corner path.append(f"V{y0 + r}") # Top-left rounded corner path.append(f"A{r},{r} 0 0 1 {x0 + r},{y0}") - + # Close the path path.append("z") - + return "".join(path) def subpath(self, box, is_active) -> str: @@ -217,17 +218,17 @@ def subpath(self, box, is_active) -> str: ne_rounded = not is_active.N and not is_active.E se_rounded = not is_active.E and not is_active.S sw_rounded = not is_active.S and not is_active.W - + coords = self.coords(box) x0 = self.img.units(coords.x0, text=False) y0 = self.img.units(coords.y0, text=False) x1 = self.img.units(coords.x1, text=False) y1 = self.img.units(coords.y1, text=False) r = self.img.units(self.corner_radius, text=False) - + # Build the path path = [] - + # Start at top-left and move clockwise if nw_rounded: # Start at top-left corner, after the rounded part @@ -235,7 +236,7 @@ def subpath(self, box, is_active) -> str: else: # Start at the top-left corner path.append(f"M{x0},{y0}") - + # Top edge to top-right corner if ne_rounded: path.append(f"H{x1 - r}") @@ -243,7 +244,7 @@ def subpath(self, box, is_active) -> str: path.append(f"A{r},{r} 0 0 1 {x1},{y0 + r}") else: path.append(f"H{x1}") - + # Right edge to bottom-right corner if se_rounded: path.append(f"V{y1 - r}") @@ -251,7 +252,7 @@ def subpath(self, box, is_active) -> str: path.append(f"A{r},{r} 0 0 1 {x1 - r},{y1}") else: path.append(f"V{y1}") - + # Bottom edge to bottom-left corner if sw_rounded: path.append(f"H{x0 + r}") @@ -259,7 +260,7 @@ def subpath(self, box, is_active) -> str: path.append(f"A{r},{r} 0 0 1 {x0},{y1 - r}") else: path.append(f"H{x0}") - + # Left edge back to start if nw_rounded: path.append(f"V{y0 + r}") @@ -267,8 +268,8 @@ def subpath(self, box, is_active) -> str: path.append(f"A{r},{r} 0 0 1 {x0 + r},{y0}") else: path.append(f"V{y0}") - + # Close the path path.append("z") - + return "".join(path) diff --git a/qrcode/tests/test_qrcode_svg.py b/qrcode/tests/test_qrcode_svg.py index 20075593..90a82563 100644 --- a/qrcode/tests/test_qrcode_svg.py +++ b/qrcode/tests/test_qrcode_svg.py @@ -60,23 +60,25 @@ def test_svg_rounded_module_drawer(): """Test that the SvgRoundedModuleDrawer works correctly.""" qr = qrcode.QRCode() qr.add_data(UNICODE_TEXT) - + # Test with default parameters module_drawer = SvgRoundedModuleDrawer() img = qr.make_image(image_factory=svg.SvgPathImage, module_drawer=module_drawer) img.save(io.BytesIO()) - + # Test with custom radius_ratio - module_drawer = SvgRoundedModuleDrawer(radius_ratio=Decimal('0.5')) + module_drawer = SvgRoundedModuleDrawer(radius_ratio=Decimal("0.5")) img = qr.make_image(image_factory=svg.SvgPathImage, module_drawer=module_drawer) img.save(io.BytesIO()) - + # Test with custom size_ratio - module_drawer = SvgRoundedModuleDrawer(size_ratio=Decimal('0.8')) + module_drawer = SvgRoundedModuleDrawer(size_ratio=Decimal("0.8")) img = qr.make_image(image_factory=svg.SvgPathImage, module_drawer=module_drawer) img.save(io.BytesIO()) - + # Test with both custom parameters - module_drawer = SvgRoundedModuleDrawer(radius_ratio=Decimal('0.3'), size_ratio=Decimal('0.9')) + module_drawer = SvgRoundedModuleDrawer( + radius_ratio=Decimal("0.3"), size_ratio=Decimal("0.9") + ) img = qr.make_image(image_factory=svg.SvgPathImage, module_drawer=module_drawer) img.save(io.BytesIO())