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())