Skip to content

Commit 3d8df95

Browse files
committed
feat: add SvgRoundedModuleDrawer class for SVG QR codes with rounded corners
1 parent 456b01d commit 3d8df95

File tree

2 files changed

+163
-0
lines changed

2 files changed

+163
-0
lines changed

qrcode/image/styles/moduledrawers/svg.py

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,3 +137,138 @@ def subpath(self, box) -> str:
137137
# x,y is the point the arc is drawn to
138138

139139
return f"M{x0},{yh}A{h},{h} 0 0 0 {x1},{yh}A{h},{h} 0 0 0 {x0},{yh}z"
140+
141+
142+
class SvgRoundedModuleDrawer(SvgPathQRModuleDrawer):
143+
"""
144+
Draws the modules with all 90 degree corners replaced with rounded edges.
145+
146+
radius_ratio determines the radius of the rounded edges - a value of 1
147+
means that an isolated module will be drawn as a circle, while a value of 0
148+
means that the radius of the rounded edge will be 0 (and thus back to 90
149+
degrees again).
150+
"""
151+
needs_neighbors = True
152+
153+
def __init__(self, radius_ratio: Decimal = Decimal(1), **kwargs):
154+
super().__init__(**kwargs)
155+
self.radius_ratio = radius_ratio
156+
157+
def initialize(self, *args, **kwargs) -> None:
158+
super().initialize(*args, **kwargs)
159+
self.corner_radius = self.box_half * self.radius_ratio
160+
161+
def drawrect(self, box, is_active):
162+
if not is_active:
163+
return
164+
165+
# Check if is_active has neighbor information (ActiveWithNeighbors object)
166+
if hasattr(is_active, 'N'):
167+
# Neighbor information is available
168+
self.img._subpaths.append(self.subpath(box, is_active))
169+
else:
170+
# No neighbor information available, draw a square with all corners rounded
171+
self.img._subpaths.append(self.subpath_all_rounded(box))
172+
173+
def subpath_all_rounded(self, box) -> str:
174+
"""Draw a module with all corners rounded."""
175+
coords = self.coords(box)
176+
x0 = self.img.units(coords.x0, text=False)
177+
y0 = self.img.units(coords.y0, text=False)
178+
x1 = self.img.units(coords.x1, text=False)
179+
y1 = self.img.units(coords.y1, text=False)
180+
r = self.img.units(self.corner_radius, text=False)
181+
182+
# Build the path with all corners rounded
183+
path = []
184+
185+
# Start at top-left after the rounded part
186+
path.append(f"M{x0 + r},{y0}")
187+
188+
# Top edge to top-right corner
189+
path.append(f"H{x1 - r}")
190+
# Top-right rounded corner
191+
path.append(f"A{r},{r} 0 0 1 {x1},{y0 + r}")
192+
193+
# Right edge to bottom-right corner
194+
path.append(f"V{y1 - r}")
195+
# Bottom-right rounded corner
196+
path.append(f"A{r},{r} 0 0 1 {x1 - r},{y1}")
197+
198+
# Bottom edge to bottom-left corner
199+
path.append(f"H{x0 + r}")
200+
# Bottom-left rounded corner
201+
path.append(f"A{r},{r} 0 0 1 {x0},{y1 - r}")
202+
203+
# Left edge to top-left corner
204+
path.append(f"V{y0 + r}")
205+
# Top-left rounded corner
206+
path.append(f"A{r},{r} 0 0 1 {x0 + r},{y0}")
207+
208+
# Close the path
209+
path.append("z")
210+
211+
return "".join(path)
212+
213+
def subpath(self, box, is_active) -> str:
214+
"""Draw a module with corners rounded based on neighbor information."""
215+
# Determine which corners should be rounded
216+
nw_rounded = not is_active.W and not is_active.N
217+
ne_rounded = not is_active.N and not is_active.E
218+
se_rounded = not is_active.E and not is_active.S
219+
sw_rounded = not is_active.S and not is_active.W
220+
221+
coords = self.coords(box)
222+
x0 = self.img.units(coords.x0, text=False)
223+
y0 = self.img.units(coords.y0, text=False)
224+
x1 = self.img.units(coords.x1, text=False)
225+
y1 = self.img.units(coords.y1, text=False)
226+
r = self.img.units(self.corner_radius, text=False)
227+
228+
# Build the path
229+
path = []
230+
231+
# Start at top-left and move clockwise
232+
if nw_rounded:
233+
# Start at top-left corner, after the rounded part
234+
path.append(f"M{x0 + r},{y0}")
235+
else:
236+
# Start at the top-left corner
237+
path.append(f"M{x0},{y0}")
238+
239+
# Top edge to top-right corner
240+
if ne_rounded:
241+
path.append(f"H{x1 - r}")
242+
# Top-right rounded corner
243+
path.append(f"A{r},{r} 0 0 1 {x1},{y0 + r}")
244+
else:
245+
path.append(f"H{x1}")
246+
247+
# Right edge to bottom-right corner
248+
if se_rounded:
249+
path.append(f"V{y1 - r}")
250+
# Bottom-right rounded corner
251+
path.append(f"A{r},{r} 0 0 1 {x1 - r},{y1}")
252+
else:
253+
path.append(f"V{y1}")
254+
255+
# Bottom edge to bottom-left corner
256+
if sw_rounded:
257+
path.append(f"H{x0 + r}")
258+
# Bottom-left rounded corner
259+
path.append(f"A{r},{r} 0 0 1 {x0},{y1 - r}")
260+
else:
261+
path.append(f"H{x0}")
262+
263+
# Left edge back to start
264+
if nw_rounded:
265+
path.append(f"V{y0 + r}")
266+
# Top-left rounded corner
267+
path.append(f"A{r},{r} 0 0 1 {x0 + r},{y0}")
268+
else:
269+
path.append(f"V{y0}")
270+
271+
# Close the path
272+
path.append("z")
273+
274+
return "".join(path)

qrcode/tests/test_qrcode_svg.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
import qrcode
44
from qrcode.image import svg
5+
from qrcode.image.styles.moduledrawers.svg import SvgRoundedModuleDrawer
6+
from decimal import Decimal
57
from qrcode.tests.consts import UNICODE_TEXT
68

79

@@ -52,3 +54,29 @@ def test_svg_circle_drawer():
5254
qr.add_data(UNICODE_TEXT)
5355
img = qr.make_image(image_factory=svg.SvgPathImage, module_drawer="circle")
5456
img.save(io.BytesIO())
57+
58+
59+
def test_svg_rounded_module_drawer():
60+
"""Test that the SvgRoundedModuleDrawer works correctly."""
61+
qr = qrcode.QRCode()
62+
qr.add_data(UNICODE_TEXT)
63+
64+
# Test with default parameters
65+
module_drawer = SvgRoundedModuleDrawer()
66+
img = qr.make_image(image_factory=svg.SvgPathImage, module_drawer=module_drawer)
67+
img.save(io.BytesIO())
68+
69+
# Test with custom radius_ratio
70+
module_drawer = SvgRoundedModuleDrawer(radius_ratio=Decimal('0.5'))
71+
img = qr.make_image(image_factory=svg.SvgPathImage, module_drawer=module_drawer)
72+
img.save(io.BytesIO())
73+
74+
# Test with custom size_ratio
75+
module_drawer = SvgRoundedModuleDrawer(size_ratio=Decimal('0.8'))
76+
img = qr.make_image(image_factory=svg.SvgPathImage, module_drawer=module_drawer)
77+
img.save(io.BytesIO())
78+
79+
# Test with both custom parameters
80+
module_drawer = SvgRoundedModuleDrawer(radius_ratio=Decimal('0.3'), size_ratio=Decimal('0.9'))
81+
img = qr.make_image(image_factory=svg.SvgPathImage, module_drawer=module_drawer)
82+
img.save(io.BytesIO())

0 commit comments

Comments
 (0)